From ba48d584e4cd1f299636d6bc245192c437f7bfdc Mon Sep 17 00:00:00 2001 From: Jsmitrah Date: Fri, 24 Jul 2026 13:23:34 +0000 Subject: [PATCH 1/5] fix: revalidate controlled inputs when value changes externally --- .../test/NumberField.test.js | 42 +++++++++++++++++++ .../src/numberfield/useNumberFieldState.ts | 10 ++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 0a3363652c6..7b7d9954079 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -256,6 +256,48 @@ describe('NumberField', () => { expect(numberfield).not.toHaveAttribute('data-invalid'); }); + it('should clear validation errors when a controlled value is updated externally', async () => { + function ControlledNumberField() { + let [value, setValue] = useState(1); + + return ( +
+ (v % 2 ? 'Odd values are invalid' : null)}> + + + + + + + + + +
+ ); + } + + let {getByRole, getByTestId} = render(); + let input = getByRole('textbox'); + + act(() => { + getByTestId('form').checkValidity(); + }); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent('Odd values are invalid'); + + await user.click(getByRole('button', {name: 'Set to 10'})); + + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + }); + it('supports pasting value in another numbering system', async () => { let {getByRole, rerender} = render(); let input = getByRole('textbox'); diff --git a/packages/react-stately/src/numberfield/useNumberFieldState.ts b/packages/react-stately/src/numberfield/useNumberFieldState.ts index eed17a1d227..6d5824729ae 100644 --- a/packages/react-stately/src/numberfield/useNumberFieldState.ts +++ b/packages/react-stately/src/numberfield/useNumberFieldState.ts @@ -24,7 +24,7 @@ import { } from '@react-types/shared'; import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; import {NumberFormatter, NumberParser} from '@internationalized/number'; -import {useCallback, useMemo, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useControlledState} from '../utils/useControlledState'; export interface NumberFieldProps @@ -185,6 +185,14 @@ export function useNumberFieldState(props: NumberFieldStateOptions): NumberField value: numberValue }); + let prevControlledValue = useRef(value); + useEffect(() => { + if (value !== undefined && !Object.is(value, prevControlledValue.current)) { + validation.commitValidation(); + } + prevControlledValue.current = value; + }, [value]); + let clampStep = step !== undefined && !isNaN(step) ? step : 1; if (intlOptions.style === 'percent' && (step === undefined || isNaN(step))) { clampStep = 0.01; From f524ab6f5e6328864ddf8ed9156e6a61e1a983ec Mon Sep 17 00:00:00 2001 From: Jsmitrah Date: Fri, 24 Jul 2026 14:14:33 +0000 Subject: [PATCH 2/5] fixed the circleCI error. --- .../test/color/ColorField.test.js | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/@adobe/react-spectrum/test/color/ColorField.test.js b/packages/@adobe/react-spectrum/test/color/ColorField.test.js index 9d0d65e6980..d9320808f69 100644 --- a/packages/@adobe/react-spectrum/test/color/ColorField.test.js +++ b/packages/@adobe/react-spectrum/test/color/ColorField.test.js @@ -64,12 +64,16 @@ describe('ColorField', function () { }); it('should allow placeholder and show warning', function () { - using spyWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - let {getByPlaceholderText, getByRole} = renderComponent({placeholder: 'Enter a color'}); - expect(getByRole('textbox')).toBe(getByPlaceholderText('Enter a color')); - expect(spyWarn).toHaveBeenCalledWith( - 'Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/ColorField.html#help-text' - ); + let spyWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + let {getByPlaceholderText, getByRole} = renderComponent({placeholder: 'Enter a color'}); + expect(getByRole('textbox')).toBe(getByPlaceholderText('Enter a color')); + expect(spyWarn).toHaveBeenCalledWith( + 'Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/ColorField.html#help-text' + ); + } finally { + spyWarn.mockRestore(); + } }); it('should show valid validation state', function () { @@ -535,7 +539,9 @@ describe('ColorField', function () { expect(input).toHaveValue('0'); let button = getByTestId('submit'); - await user.click(button); + await act(async () => { + await user.click(button); + }); expect(input).toHaveValue('255'); }); } From 12fd9f159a7c63e42a737c6208a715201fc3515f Mon Sep 17 00:00:00 2001 From: Jsmitrah Date: Thu, 13 Aug 2026 07:02:13 +0000 Subject: [PATCH 3/5] fix: sync validation for controlled inputs on external value changes (#8659) --- .../test/color/ColorField.test.js | 20 ++-- .../test/TextField.test.js | 100 +++++++++++++++++- .../react-aria/src/form/useFormValidation.ts | 70 ++++++++++-- .../src/numberfield/useNumberFieldState.ts | 10 +- 4 files changed, 167 insertions(+), 33 deletions(-) diff --git a/packages/@adobe/react-spectrum/test/color/ColorField.test.js b/packages/@adobe/react-spectrum/test/color/ColorField.test.js index d9320808f69..9d0d65e6980 100644 --- a/packages/@adobe/react-spectrum/test/color/ColorField.test.js +++ b/packages/@adobe/react-spectrum/test/color/ColorField.test.js @@ -64,16 +64,12 @@ describe('ColorField', function () { }); it('should allow placeholder and show warning', function () { - let spyWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - try { - let {getByPlaceholderText, getByRole} = renderComponent({placeholder: 'Enter a color'}); - expect(getByRole('textbox')).toBe(getByPlaceholderText('Enter a color')); - expect(spyWarn).toHaveBeenCalledWith( - 'Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/ColorField.html#help-text' - ); - } finally { - spyWarn.mockRestore(); - } + using spyWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let {getByPlaceholderText, getByRole} = renderComponent({placeholder: 'Enter a color'}); + expect(getByRole('textbox')).toBe(getByPlaceholderText('Enter a color')); + expect(spyWarn).toHaveBeenCalledWith( + 'Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/ColorField.html#help-text' + ); }); it('should show valid validation state', function () { @@ -539,9 +535,7 @@ describe('ColorField', function () { expect(input).toHaveValue('0'); let button = getByTestId('submit'); - await act(async () => { - await user.click(button); - }); + await user.click(button); expect(input).toHaveValue('255'); }); } diff --git a/packages/react-aria-components/test/TextField.test.js b/packages/react-aria-components/test/TextField.test.js index 79355365192..61ff62213c4 100644 --- a/packages/react-aria-components/test/TextField.test.js +++ b/packages/react-aria-components/test/TextField.test.js @@ -11,10 +11,11 @@ */ import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {Button} from '../src/Button'; import {FieldError} from '../src/FieldError'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; -import React from 'react'; +import React, {useState} from 'react'; import {Text} from '../src/Text'; import {TextArea} from '../src/TextArea'; import {TextField, TextFieldContext} from '../src/TextField'; @@ -266,6 +267,103 @@ describe('TextField', () => { expect(input).not.toHaveAttribute('aria-describedby'); }); + it('should clear validation errors when a controlled value is updated externally', async () => { + let Component = component; + function ControlledTextField() { + let [value, setValue] = useState(''); + + return ( +
+ + + + + + +
+ ); + } + + let {getByRole, getByTestId} = render(); + let input = getByRole('textbox'); + + act(() => { + getByTestId('form').checkValidity(); + }); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent('Constraints not satisfied'); + + await user.click(getByRole('button', {name: 'Set to Devon'})); + + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + }); + + it('should show validation errors when a controlled value is updated externally to exceed maxLength', async () => { + let Component = component; + function ControlledTextField() { + let [value, setValue] = useState(''); + + return ( +
+ + + + + + +
+ ); + } + + let {getByRole} = render(); + let input = getByRole('textbox'); + expect(input).not.toHaveAttribute('aria-describedby'); + + await user.click(getByRole('button', {name: 'Set too long'})); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent( + 'Please shorten this text to 10 characters or less' + ); + expect(input).toHaveAttribute('aria-invalid'); + }); + + it('should show validation errors when a controlled value is updated externally to be below minLength', async () => { + let Component = component; + function ControlledTextField() { + let [value, setValue] = useState(''); + + return ( +
+ + + + + + +
+ ); + } + + let {getByRole} = render(); + let input = getByRole('textbox'); + + expect(input).not.toHaveAttribute('aria-describedby'); + + await user.click(getByRole('button', {name: 'Set too short'})); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent( + 'Please lengthen this text to 10 characters or more' + ); + expect(input).toHaveAttribute('aria-invalid'); + }); + it('should render the id attribute only on the input element', async () => { let {getAllByTestId, getByRole} = render(); let outerEl = getAllByTestId('text-field-test'); diff --git a/packages/react-aria/src/form/useFormValidation.ts b/packages/react-aria/src/form/useFormValidation.ts index 5a26df343a4..537331c73f7 100644 --- a/packages/react-aria/src/form/useFormValidation.ts +++ b/packages/react-aria/src/form/useFormValidation.ts @@ -31,6 +31,7 @@ export function useFormValidation( ref: RefObject | undefined ): void { let {validationBehavior, focus} = props; + let lastValue = useRef(undefined); // This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change. useLayoutEffect(() => { @@ -40,9 +41,21 @@ export function useFormValidation( 'setCustomValidity' in ref.current && !ref.current.disabled ) { - let errorMessage = state.realtimeValidation.isInvalid - ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' - : ''; + let currentValue = ref.current.value; + let valueChanged = lastValue.current !== undefined && lastValue.current !== currentValue; + lastValue.current = currentValue; + + // Clear custom validity to accurately read the raw DOM state. + ref.current.setCustomValidity(''); + let nativeValidity = getNativeValidity(ref.current); + + // Use native validity to block form submission if constraints fail. + // Fall back to React state for server/custom errors. + let errorMessage = nativeValidity.isInvalid + ? nativeValidity.validationErrors.join(' ') || 'Invalid value.' + : state.realtimeValidation.isInvalid + ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' + : ''; ref.current.setCustomValidity(errorMessage); // Prevent default tooltip for validation message. @@ -52,7 +65,12 @@ export function useFormValidation( } if (!state.realtimeValidation.isInvalid) { - state.updateValidation(getNativeValidity(ref.current)); + state.updateValidation(nativeValidity); + } + + // Commit validation immediately if the value changed externally (e.g., while unfocused) to clear stale errors. + if (valueChanged && document.activeElement !== ref.current) { + state.commitValidation(); } } }); @@ -141,6 +159,24 @@ function getValidity(input: ValidatableElement) { // The native ValidityState object is live, meaning each property is a getter that returns the current state. // We need to create a snapshot of the validity state at the time this function is called to avoid unpredictable React renders. let validity = input.validity; + + // Polyfill: Native DOM ignores programmatic maxLength violations. + let tooLong = validity.tooLong; + if (input.getAttribute('maxlength') && input.value.length > input.maxLength) { + tooLong = true; + } + + // Polyfill: Native DOM ignores programmatic minLength violations. + // Note: minLength only applies if the value is not empty. + let tooShort = validity.tooShort; + if ( + input.getAttribute('minlength') && + input.value.length > 0 && + input.value.length < input.minLength + ) { + tooShort = true; + } + return { badInput: validity.badInput, customError: validity.customError, @@ -148,19 +184,33 @@ function getValidity(input: ValidatableElement) { rangeOverflow: validity.rangeOverflow, rangeUnderflow: validity.rangeUnderflow, stepMismatch: validity.stepMismatch, - tooLong: validity.tooLong, - tooShort: validity.tooShort, + tooLong: tooLong, + tooShort: tooShort, typeMismatch: validity.typeMismatch, valueMissing: validity.valueMissing, - valid: validity.valid + valid: validity.valid && !tooLong && !tooShort }; } function getNativeValidity(input: ValidatableElement): ValidationResult { + let validityDetails = getValidity(input); + let isInvalid = !validityDetails.valid; + + let validationMessage = input.validationMessage; + + // Fallback for our polyfills since the native DOM doesn't generate a message for programmatic errors. + if (isInvalid && !validationMessage) { + if (validityDetails.tooLong) { + validationMessage = `Please shorten this text to ${input.maxLength} characters or less (you are currently using ${input.value.length} characters).`; + } else if (validityDetails.tooShort) { + validationMessage = `Please lengthen this text to ${input.minLength} characters or more (you are currently using ${input.value.length} characters).`; + } + } + return { - isInvalid: !input.validity.valid, - validationDetails: getValidity(input), - validationErrors: input.validationMessage ? [input.validationMessage] : [] + isInvalid: isInvalid, + validationDetails: validityDetails, + validationErrors: validationMessage ? [validationMessage] : [] }; } diff --git a/packages/react-stately/src/numberfield/useNumberFieldState.ts b/packages/react-stately/src/numberfield/useNumberFieldState.ts index 6d5824729ae..eed17a1d227 100644 --- a/packages/react-stately/src/numberfield/useNumberFieldState.ts +++ b/packages/react-stately/src/numberfield/useNumberFieldState.ts @@ -24,7 +24,7 @@ import { } from '@react-types/shared'; import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; import {NumberFormatter, NumberParser} from '@internationalized/number'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useMemo, useState} from 'react'; import {useControlledState} from '../utils/useControlledState'; export interface NumberFieldProps @@ -185,14 +185,6 @@ export function useNumberFieldState(props: NumberFieldStateOptions): NumberField value: numberValue }); - let prevControlledValue = useRef(value); - useEffect(() => { - if (value !== undefined && !Object.is(value, prevControlledValue.current)) { - validation.commitValidation(); - } - prevControlledValue.current = value; - }, [value]); - let clampStep = step !== undefined && !isNaN(step) ? step : 1; if (intlOptions.style === 'percent' && (step === undefined || isNaN(step))) { clampStep = 0.01; From 0dba78fc9e7685940a9dddf8798941e219a4981a Mon Sep 17 00:00:00 2001 From: Jsmitrah Date: Thu, 13 Aug 2026 13:54:00 +0000 Subject: [PATCH 4/5] fix: sync validation for controlled inputs on external changes (#8659) Provides a comprehensive fix to evaluate programmatic constraints and safely sync validation updates without breaking compound components. --- .../react-aria/src/datepicker/useDateField.ts | 4 ++ .../react-aria/src/form/useFormValidation.ts | 44 ++++++++++++++----- .../react-aria/src/select/HiddenSelect.tsx | 1 + 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 00ecdeabcea..339964fdc88 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -100,14 +100,17 @@ export function useDateField( }); let valueOnFocus = useRef(null); + let isFocused = useRef(false); let {focusWithinProps} = useFocusWithin({ ...props, onFocusWithin(e) { valueOnFocus.current = state.value; + isFocused.current = true; props.onFocus?.(e); }, onBlurWithin: e => { state.confirmPlaceholder(); + isFocused.current = false; if (state.value !== valueOnFocus.current) { state.commitValidation(); } @@ -178,6 +181,7 @@ export function useDateField( useFormValidation( { ...props, + isFocusWithin: isFocused.current, focus() { focusManager.focusFirst(); } diff --git a/packages/react-aria/src/form/useFormValidation.ts b/packages/react-aria/src/form/useFormValidation.ts index 537331c73f7..17d9dda513f 100644 --- a/packages/react-aria/src/form/useFormValidation.ts +++ b/packages/react-aria/src/form/useFormValidation.ts @@ -23,6 +23,12 @@ type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEle interface FormValidationProps extends Validation { focus?: () => void; + /** + * Whether the field, or any part of a composite field, is currently focused. + * Used to detect external value changes in complex components where + * the validated input is not the visually active element. + */ + isFocusWithin?: boolean; } export function useFormValidation( @@ -30,7 +36,7 @@ export function useFormValidation( state: FormValidationState, ref: RefObject | undefined ): void { - let {validationBehavior, focus} = props; + let {validationBehavior, focus, isFocusWithin} = props; let lastValue = useRef(undefined); // This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change. @@ -47,15 +53,22 @@ export function useFormValidation( // Clear custom validity to accurately read the raw DOM state. ref.current.setCustomValidity(''); - let nativeValidity = getNativeValidity(ref.current); + + let validityDetails = getValidity(ref.current); + let isProgrammaticViolation = validityDetails.tooLong || validityDetails.tooShort; // Use native validity to block form submission if constraints fail. // Fall back to React state for server/custom errors. - let errorMessage = nativeValidity.isInvalid - ? nativeValidity.validationErrors.join(' ') || 'Invalid value.' - : state.realtimeValidation.isInvalid - ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' - : ''; + let errorMessage = ''; + if (isProgrammaticViolation) { + if (validityDetails.tooLong) { + errorMessage = `Please shorten this text to ${ref.current.maxLength} characters or less (you are currently using ${ref.current.value.length} characters).`; + } else if (validityDetails.tooShort) { + errorMessage = `Please lengthen this text to ${ref.current.minLength} characters or more (you are currently using ${ref.current.value.length} characters).`; + } + } else if (state.realtimeValidation.isInvalid) { + errorMessage = state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.'; + } ref.current.setCustomValidity(errorMessage); // Prevent default tooltip for validation message. @@ -64,13 +77,22 @@ export function useFormValidation( ref.current.title = ''; } - if (!state.realtimeValidation.isInvalid) { + let nativeValidity = getNativeValidity(ref.current); + if (!state.realtimeValidation.isInvalid || isProgrammaticViolation) { state.updateValidation(nativeValidity); } - // Commit validation immediately if the value changed externally (e.g., while unfocused) to clear stale errors. - if (valueChanged && document.activeElement !== ref.current) { - state.commitValidation(); + // Commit validation immediately if the value changes while the field is unfocused. + // This clears stale errors or displays programmatic constraint violations. + let isFocused = + isFocusWithin ?? + (typeof document !== 'undefined' && document.activeElement === ref.current); + + if (valueChanged && !isFocused) { + let isNowValid = !nativeValidity.isInvalid && !state.realtimeValidation.isInvalid; + if (isNowValid || isProgrammaticViolation) { + state.commitValidation(); + } } } }); diff --git a/packages/react-aria/src/select/HiddenSelect.tsx b/packages/react-aria/src/select/HiddenSelect.tsx index fee8d126111..4317da103e7 100644 --- a/packages/react-aria/src/select/HiddenSelect.tsx +++ b/packages/react-aria/src/select/HiddenSelect.tsx @@ -96,6 +96,7 @@ export function useHiddenSelect( useFormValidation( { validationBehavior, + isFocusWithin: state.isFocused, focus: () => triggerRef.current?.focus() }, state, From 767da8b2fb015bb0b80ffda287544a7540da7b92 Mon Sep 17 00:00:00 2001 From: Jsmitrah Date: Thu, 13 Aug 2026 14:34:25 +0000 Subject: [PATCH 5/5] fix: resolve TS errors for maxLength/minLength on ValidatableElement --- .../react-aria/src/form/useFormValidation.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/react-aria/src/form/useFormValidation.ts b/packages/react-aria/src/form/useFormValidation.ts index 17d9dda513f..7fdd39876f2 100644 --- a/packages/react-aria/src/form/useFormValidation.ts +++ b/packages/react-aria/src/form/useFormValidation.ts @@ -62,9 +62,9 @@ export function useFormValidation( let errorMessage = ''; if (isProgrammaticViolation) { if (validityDetails.tooLong) { - errorMessage = `Please shorten this text to ${ref.current.maxLength} characters or less (you are currently using ${ref.current.value.length} characters).`; + errorMessage = `Please shorten this text to ${ref.current.getAttribute('maxlength')} characters or less (you are currently using ${ref.current.value.length} characters).`; } else if (validityDetails.tooShort) { - errorMessage = `Please lengthen this text to ${ref.current.minLength} characters or more (you are currently using ${ref.current.value.length} characters).`; + errorMessage = `Please lengthen this text to ${ref.current.getAttribute('minlength')} characters or more (you are currently using ${ref.current.value.length} characters).`; } } else if (state.realtimeValidation.isInvalid) { errorMessage = state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.'; @@ -184,17 +184,19 @@ function getValidity(input: ValidatableElement) { // Polyfill: Native DOM ignores programmatic maxLength violations. let tooLong = validity.tooLong; - if (input.getAttribute('maxlength') && input.value.length > input.maxLength) { + let maxLength = input.getAttribute('maxlength'); + if (maxLength !== null && input.value.length > parseInt(maxLength, 10)) { tooLong = true; } // Polyfill: Native DOM ignores programmatic minLength violations. // Note: minLength only applies if the value is not empty. let tooShort = validity.tooShort; + let minLength = input.getAttribute('minlength'); if ( - input.getAttribute('minlength') && + minLength !== null && input.value.length > 0 && - input.value.length < input.minLength + input.value.length < parseInt(minLength, 10) ) { tooShort = true; } @@ -223,9 +225,9 @@ function getNativeValidity(input: ValidatableElement): ValidationResult { // Fallback for our polyfills since the native DOM doesn't generate a message for programmatic errors. if (isInvalid && !validationMessage) { if (validityDetails.tooLong) { - validationMessage = `Please shorten this text to ${input.maxLength} characters or less (you are currently using ${input.value.length} characters).`; + validationMessage = `Please shorten this text to ${input.getAttribute('maxlength')} characters or less (you are currently using ${input.value.length} characters).`; } else if (validityDetails.tooShort) { - validationMessage = `Please lengthen this text to ${input.minLength} characters or more (you are currently using ${input.value.length} characters).`; + validationMessage = `Please lengthen this text to ${input.getAttribute('minlength')} characters or more (you are currently using ${input.value.length} characters).`; } }