Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ba48d58
fix: revalidate controlled inputs when value changes externally
jsmitrah Jul 24, 2026
f524ab6
fixed the circleCI error.
jsmitrah Jul 24, 2026
823b2a2
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Jul 27, 2026
20bfbed
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Jul 28, 2026
5dbacba
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Jul 29, 2026
b4e2a46
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Jul 29, 2026
2e49430
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Jul 30, 2026
e1c9ff7
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Jul 31, 2026
ab91246
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 3, 2026
d5f14c2
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 4, 2026
fc83aa9
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 7, 2026
a031218
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 10, 2026
198e31d
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 10, 2026
2fffe4d
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 11, 2026
cca7bdb
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 12, 2026
232e4bb
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 13, 2026
12fd9f1
fix: sync validation for controlled inputs on external value changes …
jsmitrah Aug 13, 2026
0dba78f
fix: sync validation for controlled inputs on external changes (#8659)
jsmitrah Aug 13, 2026
767da8b
fix: resolve TS errors for maxLength/minLength on ValidatableElement
jsmitrah Aug 13, 2026
c59b5d0
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 14, 2026
8465761
fix: defer ref read in useDateField to satisfy react-compiler lint
jsmitrah Aug 14, 2026
e1a76b3
Trigger CircleCI
jsmitrah Aug 14, 2026
cd875a8
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 17, 2026
30d4c86
Merge branch 'main' into fix/8659/numberfield-controlled-input-valida…
jsmitrah Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/react-aria-components/test/NumberField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<form data-testid="form">
<NumberField
value={value}
onChange={setValue}
validationBehavior="native"
isRequired
validate={v => (v % 2 ? 'Odd values are invalid' : null)}>
<Label>Value</Label>
<Group>
<Button slot="decrement">-</Button>
<Input />
<Button slot="increment">+</Button>
</Group>
<FieldError />
</NumberField>
<Button onPress={() => setValue(10)}>Set to 10</Button>
</form>
);
}

let {getByRole, getByTestId} = render(<ControlledNumberField />);
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(<TestNumberField />);
let input = getByRole('textbox');
Expand Down
100 changes: 99 additions & 1 deletion packages/react-aria-components/test/TextField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<form data-testid="form">
<TextField value={value} onChange={setValue} validationBehavior="native" isRequired>
<Label>Test</Label>
<Component />
<FieldError />
</TextField>
<Button onPress={() => setValue('Devon')}>Set to Devon</Button>
</form>
);
}

let {getByRole, getByTestId} = render(<ControlledTextField />);
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 (
<form data-testid="form">
<TextField value={value} onChange={setValue} validationBehavior="native" maxLength={10}>
<Label>Test</Label>
<Component />
<FieldError />
</TextField>
<Button onPress={() => setValue('ABCDEFGHTIJKLM')}>Set too long</Button>
</form>
);
}

let {getByRole} = render(<ControlledTextField />);
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 (
<form data-testid="form">
<TextField value={value} onChange={setValue} validationBehavior="native" minLength={10}>
<Label>Test</Label>
<Component />
<FieldError />
</TextField>
<Button onPress={() => setValue('ABC')}>Set too short</Button>
</form>
);
}

let {getByRole} = render(<ControlledTextField />);
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(<TestTextField id="name" input={component} />);
let outerEl = getAllByTestId('text-field-test');
Expand Down
4 changes: 4 additions & 0 deletions packages/react-aria/src/datepicker/useDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,17 @@ export function useDateField<T extends DateValue>(
});

let valueOnFocus = useRef<DateValue | null>(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();
}
Expand Down Expand Up @@ -178,6 +181,7 @@ export function useDateField<T extends DateValue>(
useFormValidation(
{
...props,
isFocusWithin: () => isFocused.current,
focus() {
focusManager.focusFirst();
}
Expand Down
100 changes: 87 additions & 13 deletions packages/react-aria/src/form/useFormValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {FormValidationState} from 'react-stately/private/form/useFormValidationState';

import {getEventTarget} from '../utils/shadowdom/DOMFunctions';
import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions';
import {RefObject, Validation, ValidationResult} from '@react-types/shared';
import {setInteractionModality} from '../interactions/useFocusVisible';
import {useEffect, useRef} from 'react';
Expand All @@ -23,14 +23,21 @@ type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEle

interface FormValidationProps<T> extends Validation<T> {
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 | (() => boolean);
}

export function useFormValidation<T>(
props: FormValidationProps<T>,
state: FormValidationState,
ref: RefObject<ValidatableElement | null> | undefined
): void {
let {validationBehavior, focus} = props;
let {validationBehavior, focus, isFocusWithin} = props;
let lastValue = useRef<string | undefined>(undefined);

// This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change.
useLayoutEffect(() => {
Expand All @@ -40,9 +47,28 @@ export function useFormValidation<T>(
'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 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 = '';
if (isProgrammaticViolation) {
if (validityDetails.tooLong) {
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.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.';
}
ref.current.setCustomValidity(errorMessage);

// Prevent default tooltip for validation message.
Expand All @@ -51,8 +77,22 @@ export function useFormValidation<T>(
ref.current.title = '';
}

if (!state.realtimeValidation.isInvalid) {
state.updateValidation(getNativeValidity(ref.current));
let nativeValidity = getNativeValidity(ref.current);
if (!state.realtimeValidation.isInvalid || isProgrammaticViolation) {
state.updateValidation(nativeValidity);
}

// Commit validation immediately if the value changes while the field is unfocused.
// This clears stale errors or displays programmatic constraint violations.
let isFocused =
(typeof isFocusWithin === 'function' ? isFocusWithin() : isFocusWithin) ??
(typeof document !== 'undefined' && getActiveElement() === ref.current);

if (valueChanged && !isFocused) {
let isNowValid = !nativeValidity.isInvalid && !state.realtimeValidation.isInvalid;
if (isNowValid || isProgrammaticViolation) {
state.commitValidation();
}
}
}
});
Expand Down Expand Up @@ -141,26 +181,60 @@ 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;
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 (
minLength !== null &&
input.value.length > 0 &&
input.value.length < parseInt(minLength, 10)
) {
tooShort = true;
}

return {
badInput: validity.badInput,
customError: validity.customError,
patternMismatch: validity.patternMismatch,
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.getAttribute('maxlength')} characters or less (you are currently using ${input.value.length} characters).`;
} else if (validityDetails.tooShort) {
validationMessage = `Please lengthen this text to ${input.getAttribute('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] : []
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/react-aria/src/select/HiddenSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function useHiddenSelect<T, M extends SelectionMode = 'single'>(
useFormValidation(
{
validationBehavior,
isFocusWithin: state.isFocused,
focus: () => triggerRef.current?.focus()
},
state,
Expand Down