From b19287a027a00184cb8f3360468cb9b1d95f6385 Mon Sep 17 00:00:00 2001 From: Zlata <129689245+kkatalz@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:27:52 +0300 Subject: [PATCH] fix(form-core): [v2] revalidate existing child fields when a parent value is set `setFieldValue` clears event errors on the target field and validates it and its ancestors only, so a child field whose value changed through a parent set (`setFieldValue('a', { b: 1 })`) kept its change, submit, server or mount errors and the form stayed invalid. Each existing descendant whose value changed now gets what a direct change gives it: its submit, server and mount errors cleared and its change validators run. This moves the subtree revalidation `replaceFieldValue` did on its own into `setFieldValue`. Array helpers that move or kill child fields after setting the value opt out, so no field is validated against its neighbour's value. --- .changeset/revalidate-child-fields.md | 5 ++ .../form-core/src/FieldApi/FieldApi.lib.ts | 2 +- packages/form-core/src/FormApi/FormApi.lib.ts | 28 ++++++ .../src/FormApi/array-methods.lib.ts | 22 ++--- packages/form-core/src/types.lib.ts | 8 ++ packages/form-core/src/utils.lib.ts | 1 + .../tests/FormApi/validation.spec.ts | 85 +++++++++++++++++++ .../form-core/tests/serverValidate.spec.ts | 28 ++++++ packages/form-core/tests/utils.test.ts | 3 + 9 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 .changeset/revalidate-child-fields.md diff --git a/.changeset/revalidate-child-fields.md b/.changeset/revalidate-child-fields.md new file mode 100644 index 0000000000..49018c2ebd --- /dev/null +++ b/.changeset/revalidate-child-fields.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Revalidate existing child fields when a parent value is set, and clear their submit, server and mount errors. diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 55e6e41376..0cafac7632 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -965,7 +965,7 @@ export class InternalFieldApi< _notifyEvent( options: Omit< ResolvedInternalFieldUpdateOptions, - 'fieldApiOverride' | '_skipFieldCreation' + 'fieldApiOverride' | '_skipFieldCreation' | '_skipDescendantValidation' >, event: 'change' | 'blur' | 'submit', ): void { diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 4096aca4b3..9190c8ebfb 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -507,6 +507,7 @@ export class InternalFormApi< updateOptions.fieldApiOverride = field batch(() => { + const previousValues = this._atoms.values.get() const previousValue = this.getFieldValue(fieldName) const nextValue = callUpdater(updater, previousValue) const replacedSameLengthArray = @@ -525,6 +526,14 @@ export class InternalFormApi< } this._notifyFieldChange(field, updateOptions) + + if ( + field && + updateOptions.causeValidation && + !updateOptions._skipDescendantValidation + ) { + this._notifyDescendantsChange(field, previousValues) + } }) notifyDevtoolsFieldValueUpdate(field) @@ -693,6 +702,25 @@ export class InternalFormApi< this._notifyFormListener('change', field) } + _notifyDescendantsChange( + field: AnyInternalFieldApi, + previousValues: TFormData, + ) { + const seenValidatorFields = new WeakSet() + + for (const descendant of collectFieldSubtree(field)) { + if (descendant === field) continue + if (getBy(previousValues, descendant.name) === descendant.value) continue + + this._clearEventErrors(descendant, 'submit', 'change') + this._clearEventErrors(descendant, 'server', 'change') + this._clearEventErrors(descendant, 'mount', 'change') + descendant._clearEventErrors('change', 'submit') + descendant._runFieldValidation('change') + descendant._notifyValidator('change', seenValidatorFields) + } + } + _notifyFormListener( trigger: FormListenerTriggers, triggerFieldApi: AnyInternalFieldApi | null, diff --git a/packages/form-core/src/FormApi/array-methods.lib.ts b/packages/form-core/src/FormApi/array-methods.lib.ts index 31f1459c70..922f56de3d 100644 --- a/packages/form-core/src/FormApi/array-methods.lib.ts +++ b/packages/form-core/src/FormApi/array-methods.lib.ts @@ -1,8 +1,6 @@ import { batch } from '@tanstack/store' import { getTargetField, resolveFieldUpdateOptions } from '../utils.lib' import { tryGetFieldApi } from '../FieldApi/FieldApi.lib' -import { collectFieldSubtree } from '../FieldApi/fieldTraversal.lib' -import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { AnyInternalFormApi } from './FormApi.lib' import type { InternalFieldUpdateOptions } from '../types.lib' @@ -86,6 +84,7 @@ function insertFieldValue({ const updateOptions = resolveFieldUpdateOptions(options, 'change') const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField + updateOptions._skipDescendantValidation = true batch(() => { form.setFieldValue( @@ -146,20 +145,6 @@ function replaceFieldValue({ }, updateOptions, ) - - if (!arrayField || !updateOptions.causeValidation) return - - // The change cascade above only validates the array field and its ancestors. - // Revalidate the replaced element's existing subtree so errors computed - // for the old value don't linger. - const replacedField = tryGetFieldApi(arrayField, [index]) - if (!replacedField) return - - const seenValidatorFields = new WeakSet() - for (const field of collectFieldSubtree(replacedField)) { - field._runFieldValidation('change') - field._notifyValidator('change', seenValidatorFields) - } } function removeFieldValue({ @@ -185,6 +170,7 @@ function removeFieldValue({ const updateOptions = resolveFieldUpdateOptions(options, 'change') const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField + updateOptions._skipDescendantValidation = true batch(() => { form.setFieldValue( @@ -240,6 +226,7 @@ function swapFieldValues({ const updateOptions = resolveFieldUpdateOptions(options, 'change') const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField + updateOptions._skipDescendantValidation = true batch(() => { form.setFieldValue( @@ -299,6 +286,7 @@ function moveFieldValue({ const updateOptions = resolveFieldUpdateOptions(options, 'change') const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField + updateOptions._skipDescendantValidation = true batch(() => { form.setFieldValue( @@ -349,6 +337,7 @@ function clearFieldValues({ const updateOptions = resolveFieldUpdateOptions(options, 'change') const arrayField = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayField + updateOptions._skipDescendantValidation = true batch(() => { form.setFieldValue(arrayFieldName, [], updateOptions) @@ -381,6 +370,7 @@ function filterFieldValues({ const updateOptions = resolveFieldUpdateOptions(options, 'change') const arrayNode = getTargetField(form, arrayFieldName, updateOptions) updateOptions.fieldApiOverride = arrayNode + updateOptions._skipDescendantValidation = true const oldArray: Array = form.getFieldValue(arrayFieldName) diff --git a/packages/form-core/src/types.lib.ts b/packages/form-core/src/types.lib.ts index 3e2191ab35..bcf8aa7f4c 100644 --- a/packages/form-core/src/types.lib.ts +++ b/packages/form-core/src/types.lib.ts @@ -19,6 +19,14 @@ export interface InternalFieldUpdateOptions * @default boolean - derived from whether field meta needs to be stored */ _skipFieldCreation?: boolean + /** + * @private + * Whether to skip revalidating existing descendants whose value changed. + * Set by array methods that move child fields after setting the value. + * + * @default false + */ + _skipDescendantValidation?: boolean } export interface PropagateOptions { diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index d1e0005cb0..a27fefbf8a 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -65,6 +65,7 @@ export function resolveFieldUpdateOptions( return { ...baseOpts, _skipFieldCreation: options?._skipFieldCreation ?? noFieldCreationNeeded, + _skipDescendantValidation: options?._skipDescendantValidation === true, fieldApiOverride: options?.fieldApiOverride ?? null, doPropagate: options?.doPropagate ?? true, } diff --git a/packages/form-core/tests/FormApi/validation.spec.ts b/packages/form-core/tests/FormApi/validation.spec.ts index 2ca600b9a8..8efbe4b576 100644 --- a/packages/form-core/tests/FormApi/validation.spec.ts +++ b/packages/form-core/tests/FormApi/validation.spec.ts @@ -1116,6 +1116,91 @@ describe('form - validation', () => { expect(validatorFn).toHaveBeenCalledTimes(2) expect(form.state.errors).toEqual([{ message: 'Current error' }]) }) + + it('revalidates existing child fields when a parent value is set', async () => { + const form = new InternalFormApi({ defaultValues: { a: { b: 1 } } }) + const field = form._getOrCreateFieldApi({ + name: 'a.b', + validators: [ + { + run: ({ value }: { value: number }) => + value > 0 ? undefined : 'must be > 0', + triggers: ['change'], + }, + ], + }) + field._register() + + field.handleChange(0) + await vi.waitFor(() => + expect(field.errors).toEqual([{ message: 'must be > 0' }]), + ) + + form.setFieldValue('a', { b: 1 }) + + expect(field.value).toBe(1) + await vi.waitFor(() => expect(field.errors).toEqual([])) + expect(form.state.isValid).toBe(true) + }) + + it('clears submit and mount errors of existing child fields when a parent value is set', async () => { + const form = new InternalFormApi({ + defaultValues: { a: { b: 0 } }, + validators: [ + { + run: () => ({ fields: { 'a.b': 'Mount error' } }), + runOnMount: true, + runOnSubmit: false, + triggers: [], + }, + { + run: () => ({ fields: { 'a.b': 'Form submit error' } }), + triggers: [], + }, + ], + }) + const field = form._getOrCreateFieldApi({ + name: 'a.b', + validators: [{ run: () => 'Field submit error', triggers: [] }], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([ + { message: 'Field submit error' }, + { message: 'Mount error' }, + { message: 'Form submit error' }, + ]) + + form.setFieldValue('a', { b: 1 }) + + expect(field.errors).toEqual([]) + }) + + it('does not revalidate child fields that array helpers move', () => { + const validator = vi.fn(() => undefined) + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + form._getOrCreateFieldApi({ name: 'items' })._register() + for (const name of ['items[0]', 'items[1]', 'items[2]']) { + form + ._getOrCreateFieldApi({ + name, + validators: [{ run: validator, triggers: ['change'] }], + }) + ._register() + } + + form.swapFieldValues('items', 0, 1) + form.moveFieldValue('items', 0, 2) + form.insertFieldValue('items', 0, 'x') + form.removeFieldValue('items', 0) + form.filterFieldValues('items', (value) => value !== 'c') + form.clearFieldValues('items') + + expect(validator).not.toHaveBeenCalled() + }) }) describe('field error boundaries', () => { diff --git a/packages/form-core/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index 5a6bb81f1a..d541db31a2 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -481,6 +481,34 @@ describe('server validation', () => { expect(blurEmailField.errors).toEqual([{ message: 'Server email error' }]) }) + it('clears server errors of existing child fields when a parent value is set', async () => { + const options = formOptions({ + defaultValues: { user: { name: '' } }, + validators: [ + { + run: () => ({ fields: { 'user.name': 'Server name error' } }), + triggers: ['server'], + }, + ], + }) + + const { serverState } = expectServerValidateFailure( + await validateServerValues(options, { user: { name: '' } }), + ) + + const form = new InternalFormApi({ + ...options, + serverState, + } as never) + const field = form._getOrCreateFieldApi({ name: 'user.name' }) + + expect(field.errors).toEqual([{ message: 'Server name error' }]) + + form.setFieldValue('user', { name: 'Alice' }) + + expect(field.errors).toEqual([]) + }) + it('clears stale server errors when a new server state arrives', async () => { const options = formOptions({ defaultValues: { name: '' }, diff --git a/packages/form-core/tests/utils.test.ts b/packages/form-core/tests/utils.test.ts index be555831bf..734319b965 100644 --- a/packages/form-core/tests/utils.test.ts +++ b/packages/form-core/tests/utils.test.ts @@ -21,6 +21,7 @@ describe('utils', () => { markAsDirty: true, markAsTouched: true, _skipFieldCreation: false, + _skipDescendantValidation: false, fieldApiOverride: null, doPropagate: true, }) @@ -56,6 +57,7 @@ describe('utils', () => { markAsDirty: false, markAsTouched: false, _skipFieldCreation: false, + _skipDescendantValidation: true, fieldApiOverride, doPropagate: false, }, @@ -67,6 +69,7 @@ describe('utils', () => { markAsDirty: false, markAsTouched: false, _skipFieldCreation: false, + _skipDescendantValidation: true, fieldApiOverride, doPropagate: false, })