Skip to content

Commit 362b0b5

Browse files
authored
[field] Revalidate only required on change (#1840)
1 parent e9bfb01 commit 362b0b5

File tree

3 files changed

+207
-6
lines changed

3 files changed

+207
-6
lines changed

packages/react/src/field/control/useFieldControlValidation.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,28 @@ import type { HTMLProps } from '../../utils/types';
1111

1212
const validityKeys = Object.keys(DEFAULT_VALIDITY_STATE) as Array<keyof ValidityState>;
1313

14+
function isOnlyValueMissing(state: Record<keyof ValidityState, boolean> | undefined) {
15+
if (!state || state.valid || !state.valueMissing) {
16+
return false;
17+
}
18+
19+
let onlyValueMissing = false;
20+
21+
for (const key of validityKeys) {
22+
if (key === 'valid') {
23+
continue;
24+
}
25+
if (key === 'valueMissing') {
26+
onlyValueMissing = state[key];
27+
}
28+
if (state[key]) {
29+
onlyValueMissing = false;
30+
}
31+
}
32+
33+
return onlyValueMissing;
34+
}
35+
1436
export function useFieldControlValidation() {
1537
const {
1638
setValidityData,
@@ -37,8 +59,57 @@ export function useFieldControlValidation() {
3759
return;
3860
}
3961

40-
if (revalidate && state.valid !== false) {
41-
return;
62+
if (revalidate) {
63+
if (state.valid !== false) {
64+
return;
65+
}
66+
67+
const currentNativeValidity = element.validity;
68+
69+
if (!currentNativeValidity.valueMissing) {
70+
// The 'valueMissing' (required) condition has been resolved by the user typing.
71+
// Temporarily mark the field as valid for this onChange event.
72+
// Other native errors (e.g., typeMismatch) will be caught by full validation on blur or submit.
73+
const nextValidityData = {
74+
value,
75+
state: { ...DEFAULT_VALIDITY_STATE, valid: true },
76+
error: '',
77+
errors: [],
78+
initialValue: validityData.initialValue,
79+
};
80+
element.setCustomValidity('');
81+
82+
if (controlId) {
83+
const currentFieldData = formRef.current.fields.get(controlId);
84+
if (currentFieldData) {
85+
formRef.current.fields.set(controlId, {
86+
...currentFieldData,
87+
...getCombinedFieldValidityData(nextValidityData, false), // invalid = false
88+
});
89+
}
90+
}
91+
setValidityData(nextValidityData);
92+
return;
93+
}
94+
95+
// Value is still missing, or other conditions apply.
96+
// Let's use a representation of current validity for isOnlyValueMissing.
97+
const currentNativeValidityObject = validityKeys.reduce(
98+
(acc, key) => {
99+
acc[key] = currentNativeValidity[key];
100+
return acc;
101+
},
102+
{} as Record<keyof ValidityState, boolean>,
103+
);
104+
105+
// If it's (still) natively invalid due to something other than just valueMissing,
106+
// then bail from this revalidation on change to avoid "scolding" for other errors.
107+
if (!currentNativeValidityObject.valid && !isOnlyValueMissing(currentNativeValidityObject)) {
108+
return;
109+
}
110+
111+
// If valueMissing is still true AND it's the only issue, or if the field is now natively valid,
112+
// let it fall through to the main validation logic below.
42113
}
43114

44115
function getState(el: HTMLInputElement) {
@@ -148,9 +219,13 @@ export function useFieldControlValidation() {
148219
}
149220

150221
clearErrors(name);
151-
commitValidation(event.currentTarget.value, true);
152222

153-
if (invalid || validationMode !== 'onChange') {
223+
if (validationMode !== 'onChange') {
224+
commitValidation(event.currentTarget.value, true);
225+
return;
226+
}
227+
228+
if (invalid) {
154229
return;
155230
}
156231

packages/react/src/field/root/FieldRoot.test.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,120 @@ describe('<Field.Root />', () => {
336336
});
337337
});
338338

339+
describe('revalidation', () => {
340+
it('revalidates on change for `valueMissing`', async () => {
341+
await render(
342+
<Field.Root>
343+
<Field.Control required />
344+
<Field.Error />
345+
</Field.Root>,
346+
);
347+
348+
const control = screen.getByRole('textbox');
349+
const message = screen.queryByText('error');
350+
351+
expect(message).to.equal(null);
352+
353+
fireEvent.focus(control);
354+
fireEvent.change(control, { target: { value: 't' } });
355+
fireEvent.blur(control);
356+
357+
expect(control).not.to.have.attribute('aria-invalid', 'true');
358+
359+
fireEvent.focus(control);
360+
fireEvent.change(control, { target: { value: '' } });
361+
fireEvent.blur(control);
362+
363+
expect(control).to.have.attribute('aria-invalid');
364+
});
365+
366+
it('handles both `required` and `typeMismatch`', async () => {
367+
await render(
368+
<Field.Root>
369+
<Field.Control type="email" required />
370+
<Field.Error data-testid="error" />
371+
</Field.Root>,
372+
);
373+
374+
const control = screen.getByRole('textbox');
375+
const message = screen.queryByTestId('error');
376+
377+
expect(message).to.equal(null);
378+
379+
fireEvent.focus(control);
380+
fireEvent.blur(control);
381+
382+
expect(control).not.to.have.attribute('aria-invalid');
383+
384+
fireEvent.focus(control);
385+
fireEvent.change(control, { target: { value: 'tt' } });
386+
fireEvent.blur(control);
387+
388+
expect(control).to.have.attribute('aria-invalid', 'true');
389+
390+
fireEvent.focus(control);
391+
fireEvent.change(control, { target: { value: '' } });
392+
fireEvent.blur(control);
393+
394+
expect(control).to.have.attribute('aria-invalid', 'true');
395+
396+
fireEvent.focus(control);
397+
fireEvent.change(control, { target: { value: '[email protected]' } });
398+
fireEvent.blur(control);
399+
400+
expect(control).not.to.have.attribute('aria-invalid');
401+
});
402+
403+
it('clears valueMissing on change but defers other native errors like typeMismatch until blur when both are active', async () => {
404+
await render(
405+
<Field.Root>
406+
<Field.Control type="email" required data-testid="control" />
407+
<Field.Error data-testid="error" />
408+
</Field.Root>,
409+
);
410+
411+
const control = screen.getByTestId('control');
412+
413+
fireEvent.focus(control);
414+
fireEvent.blur(control);
415+
expect(control).not.to.have.attribute('aria-invalid', 'true');
416+
expect(screen.queryByTestId('error')).to.equal(null);
417+
418+
fireEvent.focus(control);
419+
fireEvent.change(control, { target: { value: 'a' } });
420+
fireEvent.change(control, { target: { value: '' } });
421+
fireEvent.blur(control);
422+
423+
expect(control).to.have.attribute('aria-invalid', 'true');
424+
expect(screen.getByTestId('error')).not.to.equal(null);
425+
426+
fireEvent.focus(control);
427+
fireEvent.change(control, { target: { value: 't' } });
428+
429+
// The field becomes temporarily valid because only 'valueMissing' is checked for immediate clearing.
430+
// Other errors like 'typeMismatch' are deferred to the next blur/submit.
431+
expect(control).not.to.have.attribute('aria-invalid', 'true');
432+
expect(screen.queryByTestId('error')).to.equal(null);
433+
434+
fireEvent.blur(control);
435+
436+
expect(control).to.have.attribute('aria-invalid', 'true');
437+
expect(screen.getByTestId('error')).not.to.equal(null);
438+
expect(screen.getByTestId('error').textContent).not.to.equal('');
439+
440+
fireEvent.focus(control);
441+
fireEvent.change(control, { target: { value: '[email protected]' } });
442+
443+
expect(control).not.to.have.attribute('aria-invalid', 'true');
444+
expect(screen.queryByTestId('error')).to.equal(null);
445+
446+
fireEvent.blur(control);
447+
448+
expect(control).not.to.have.attribute('aria-invalid', 'true');
449+
expect(screen.queryByTestId('error')).to.equal(null);
450+
});
451+
});
452+
339453
describe('style hooks', () => {
340454
describe('touched', () => {
341455
it('should apply [data-touched] style hook to all components when touched', async () => {

packages/react/src/number-field/input/NumberFieldInput.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
6767
} = useFieldControlValidation();
6868

6969
const hasTouchedInputRef = React.useRef(false);
70+
const blockRevalidationRef = React.useRef(false);
7071

7172
const handleInputRef = useForkRef(forwardedRef, inputRef, inputValidationRef);
7273

@@ -89,11 +90,21 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
8990

9091
if (validationMode === 'onChange') {
9192
commitValidation(value);
92-
} else {
93-
commitValidation(value, true);
9493
}
9594
}, [value, inputValue, name, clearErrors, validationMode, commitValidation]);
9695

96+
useModernLayoutEffect(() => {
97+
if (prevValueRef.current === value || validationMode === 'onChange') {
98+
return;
99+
}
100+
101+
if (blockRevalidationRef.current) {
102+
blockRevalidationRef.current = false;
103+
return;
104+
}
105+
commitValidation(value, true);
106+
}, [commitValidation, validationMode, value]);
107+
97108
useModernLayoutEffect(() => {
98109
prevValueRef.current = value;
99110
prevInputValueRef.current = inputValue;
@@ -156,6 +167,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
156167
const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current);
157168

158169
if (parsedValue !== null) {
170+
blockRevalidationRef.current = true;
159171
setValue(parsedValue, event.nativeEvent);
160172
if (validationMode === 'onBlur') {
161173
commitValidation(parsedValue);

0 commit comments

Comments
 (0)