From 70e92d4a1c7818781250321cf13c474631859f04 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Mon, 13 Jul 2026 15:56:42 +0800 Subject: [PATCH] fix(form-core): clear stale onMount error on linked-field revalidation A field-level `onMount` error was cleared when the field's own value changed (via `setFieldValue`), but never when the field was revalidated indirectly through `onChangeListenTo`/`onBlurListenTo`. Because a linked revalidation does not change the field's value, the mount-time error lingered even after the field's validators passed, leaving the field (and the form) permanently invalid. Clear the stale `onMount` slot when a passing linked-field revalidation supersedes it, mirroring the existing value-change behavior. Closes #2124 --- ...clear-stale-onmount-linked-revalidation.md | 5 ++ packages/form-core/src/FieldApi.ts | 29 ++++++++++- packages/form-core/tests/FieldApi.spec.ts | 51 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 .changeset/clear-stale-onmount-linked-revalidation.md diff --git a/.changeset/clear-stale-onmount-linked-revalidation.md b/.changeset/clear-stale-onmount-linked-revalidation.md new file mode 100644 index 000000000..415f5e524 --- /dev/null +++ b/.changeset/clear-stale-onmount-linked-revalidation.md @@ -0,0 +1,5 @@ +--- +"@tanstack/form-core": patch +--- + +fix(form-core): clear a stale field-level `onMount` error when a linked-field revalidation (`onChangeListenTo`/`onBlurListenTo`) passes, so the field is no longer left permanently invalid diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 80770f889..c2aa6c3a1 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1292,6 +1292,7 @@ export class FieldApi< const validateFieldFn = ( field: AnyFieldApi, validateObj: SyncValidator, + isLinkedField = false, ) => { const errorMapKey = getErrorMapKey(validateObj.cause) @@ -1334,6 +1335,32 @@ export class FieldApi< if (newErrorValue) { hasErrored = true } + + // A direct value change clears the mount-time error in + // `setFieldValue`. When a field is revalidated indirectly via + // `onChangeListenTo`/`onBlurListenTo`, its own value never changed, so + // that clearing never ran and a now-stale `onMount` error would linger + // forever (leaving the field permanently invalid even though its + // validators now pass). Clear it once a passing linked revalidation + // supersedes it. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if ( + isLinkedField && + !newErrorValue && + field.state.meta.errorMap?.onMount + ) { + field.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + onMount: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + onMount: undefined, + }, + })) + } } for (const validateObj of validates) { @@ -1341,7 +1368,7 @@ export class FieldApi< } for (const fieldValitateObj of linkedFieldValidates) { if (!fieldValitateObj.validate) continue - validateFieldFn(fieldValitateObj.field, fieldValitateObj) + validateFieldFn(fieldValitateObj.field, fieldValitateObj, true) } }) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 9c3ed43e4..e58ea93e6 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2301,6 +2301,57 @@ describe('field api', () => { ]) }) + it('should clear a stale onMount error when a linked field revalidation passes', () => { + const form = new FormApi({ + defaultValues: { + password: '', + confirm_password: 'password', + }, + }) + + form.mount() + + const passField = new FieldApi({ + form, + name: 'password', + }) + + const passconfirmField = new FieldApi({ + form, + name: 'confirm_password', + validators: { + onMount: ({ value, fieldApi }) => + value !== fieldApi.form.getFieldValue('password') + ? 'Passwords do not match' + : undefined, + onChangeListenTo: ['password'], + onChange: ({ value, fieldApi }) => + value !== fieldApi.form.getFieldValue('password') + ? 'Passwords do not match' + : undefined, + }, + }) + + passField.mount() + passconfirmField.mount() + + // onMount reports the mismatch (password === '', confirm === 'password'). + expect(passconfirmField.state.meta.errorMap.onMount).toBe( + 'Passwords do not match', + ) + expect(passconfirmField.getMeta().isValid).toBe(false) + + // Changing the linked field re-runs confirm's onChange (which now passes) + // without touching confirm's own value. The stale onMount error should + // clear, just as it would if the field's value had changed directly. + passField.setValue('password') + + expect(passconfirmField.state.meta.errorMap.onMount).toBeUndefined() + expect(passconfirmField.state.meta.errors).toStrictEqual([]) + expect(passconfirmField.getMeta().isValid).toBe(true) + expect(form.state.isValid).toBe(true) + }) + it('should run onBlur on a linked field', () => { const form = new FormApi({ defaultValues: {