From 2afa535c6ea5985d3a4ab1e37017e3298b03566d Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 6 Mar 2026 13:15:30 +0100 Subject: [PATCH 01/12] fix: make field unmount --- packages/form-core/src/FieldApi.ts | 55 ++++++++++- packages/form-core/tests/FieldApi.spec.ts | 58 +++++++++++ packages/react-form/tests/useField.test.tsx | 101 ++++++++++++++++++++ 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index f8b1b009e..3195d36d5 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1322,8 +1322,59 @@ export class FieldApi< fieldApi: this, }) - // TODO: Remove - return () => {} + return () => { + // Stop any in-flight async validation or listener work tied to this instance. + for (const key of Object.keys( + this.timeoutIds.validations, + ) as ValidationCause[]) { + const timeout = this.timeoutIds.validations[key] + if (timeout) { + clearTimeout(timeout) + this.timeoutIds.validations[key] = null + } + } + for (const key of Object.keys( + this.timeoutIds.listeners, + ) as ListenerCause[]) { + const timeout = this.timeoutIds.listeners[key] + if (timeout) { + clearTimeout(timeout) + this.timeoutIds.listeners[key] = null + } + } + for (const key of Object.keys( + this.timeoutIds.formListeners, + ) as ListenerCause[]) { + const timeout = this.timeoutIds.formListeners[key] + if (timeout) { + clearTimeout(timeout) + this.timeoutIds.formListeners[key] = null + } + } + + const fieldInfo = this.form.fieldInfo[this.name] + + for (const key of Object.keys(fieldInfo.validationMetaMap) as Array< + keyof typeof fieldInfo.validationMetaMap + >) { + fieldInfo.validationMetaMap[key]?.lastAbortController.abort() + fieldInfo.validationMetaMap[key] = undefined + } + + // If a newer field instance has already been mounted for this name, + // avoid clearing its state during teardown of an older instance. + if (fieldInfo.instance !== this) return + + this.form.baseStore.setState((prev) => ({ + ...prev, + fieldMetaBase: { + ...prev.fieldMetaBase, + [this.name]: defaultFieldMeta, + }, + })) + + fieldInfo.instance = null + } } /** diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index f9d099022..07d090b30 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1603,6 +1603,64 @@ describe('field api', () => { expect(form.getFieldInfo(field.name)).toBeDefined() }) + it('should clear meta on unmount while preserving value', async () => { + const form = new FormApi({ + defaultValues: { + firstName: 'a', + lastName: 'abc', + }, + onSubmit: () => {}, + }) + + form.mount() + + const firstName = new FieldApi({ + form, + name: 'firstName', + }) + const lastName = new FieldApi({ + form, + name: 'lastName', + validators: { + onSubmit: ({ value }) => + value.length >= 5 ? undefined : 'last name must be at least 5 chars', + }, + }) + + firstName.mount() + const unmountLastName = lastName.mount() + + await form.handleSubmit() + expect(form.state.canSubmit).toBe(false) + expect(lastName.getMeta().errors).toContain( + 'last name must be at least 5 chars', + ) + + unmountLastName() + + expect(form.getFieldValue('lastName')).toBe('abc') + expect(form.state.fieldMeta.lastName).toMatchObject({ + isTouched: false, + isValid: true, + errors: [], + }) + expect(form.state.canSubmit).toBe(true) + + const remountedLastName = new FieldApi({ + form, + name: 'lastName', + validators: { + onSubmit: ({ value }) => + value.length >= 5 ? undefined : 'last name must be at least 5 chars', + }, + }) + + remountedLastName.mount() + expect(remountedLastName.getMeta().errors).toStrictEqual([]) + expect(remountedLastName.getMeta().isTouched).toBe(false) + expect(remountedLastName.getValue()).toBe('abc') + }) + it('should show onSubmit errors', async () => { const form = new FormApi({ defaultValues: { diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index 861ad9df5..45996ce97 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -328,6 +328,107 @@ describe('useField', () => { expect((getByTestId('first-field') as HTMLInputElement).value).toBe('hello') }) + it('should not keep hidden field submit errors after unmount', async () => { + const onSubmit = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + onSubmit: ({ value }) => onSubmit(value), + }) + + return ( +
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + + state.values.firstName === 'a'}> + {(showLastName) => + showLastName ? ( + + value.length >= 5 ? undefined : 'lastName too short', + }} + > + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + ) : null + } + + + [state.canSubmit, state.isSubmitting]} + > + {([canSubmit, isSubmitting]) => ( + + )} + +
+ ) + } + + const { getByTestId, queryByTestId } = render( + + + , + ) + + const submitButton = getByTestId('submit') + + await user.type(getByTestId('first-name'), 'a') + await user.type(getByTestId('last-name'), 'abc') + await user.click(submitButton) + + await waitFor(() => expect(submitButton).toBeDisabled()) + expect(onSubmit).toHaveBeenCalledTimes(0) + + await user.clear(getByTestId('first-name')) + await user.type(getByTestId('first-name'), 'b') + + await waitFor(() => + expect(queryByTestId('last-name')).not.toBeInTheDocument(), + ) + await waitFor(() => expect(submitButton).toBeEnabled()) + + await user.click(submitButton) + expect(onSubmit).toHaveBeenCalledTimes(1) + + await user.clear(getByTestId('first-name')) + await user.type(getByTestId('first-name'), 'a') + + const remountedLastName = await waitFor(() => getByTestId('last-name')) + expect((remountedLastName as HTMLInputElement).value).toBe('abc') + expect(submitButton).toBeEnabled() + }) + it('should validate async on change', async () => { type Person = { firstName: string From 29038e6c36205f679b6d912c7f1e41d5b6c4d25a Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 6 Mar 2026 13:18:05 +0100 Subject: [PATCH 02/12] more tests --- packages/form-core/tests/FieldApi.spec.ts | 114 ++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 07d090b30..3179a9286 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1661,6 +1661,120 @@ describe('field api', () => { expect(remountedLastName.getValue()).toBe('abc') }) + it('should not apply in-flight async validation results after unmount', async () => { + vi.useFakeTimers() + + let resolveValidation!: () => void + const validationPromise = new Promise((resolve) => { + resolveValidation = resolve + }) + + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsyncDebounceMs: 0, + onChangeAsync: async () => { + await validationPromise + return 'async error should be ignored after unmount' + }, + }, + }) + + const unmount = field.mount() + + field.setValue('trigger') + await vi.runAllTimersAsync() + + unmount() + resolveValidation() + await vi.runAllTimersAsync() + + expect(form.state.fieldMeta.name).toMatchObject({ + isTouched: false, + isValid: true, + errors: [], + }) + + vi.useRealTimers() + }) + + it('should cancel debounced field and form listeners on unmount', async () => { + vi.useFakeTimers() + + const fieldListener = vi.fn() + const formListener = vi.fn() + + const form = new FormApi({ + defaultValues: { + name: '', + }, + listeners: { + onChange: formListener, + onChangeDebounceMs: 200, + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + listeners: { + onChange: fieldListener, + onChangeDebounceMs: 200, + }, + }) + + const unmount = field.mount() + field.setValue('trigger') + unmount() + + await vi.advanceTimersByTimeAsync(500) + + expect(fieldListener).toHaveBeenCalledTimes(0) + expect(formListener).toHaveBeenCalledTimes(0) + + vi.useRealTimers() + }) + + it('should not clear newer instance state when older instance unmounts', () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + form.mount() + + const oldField = new FieldApi({ + form, + name: 'name', + }) + const oldUnmount = oldField.mount() + + const newField = new FieldApi({ + form, + name: 'name', + }) + newField.mount() + newField.setValue('new value') + + oldUnmount() + + expect(form.getFieldInfo('name').instance).toBe(newField) + expect(newField.getValue()).toBe('new value') + expect(newField.getMeta().isTouched).toBe(true) + }) + it('should show onSubmit errors', async () => { const form = new FormApi({ defaultValues: { From eb780f78b7f21f2b8fc477a4cb7566ffcfbc8d90 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 6 Mar 2026 13:23:19 +0100 Subject: [PATCH 03/12] ref: prefer object.entries --- packages/form-core/src/FieldApi.ts | 39 ++++++++++++++++-------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 3195d36d5..9defe76c3 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1324,41 +1324,44 @@ export class FieldApi< return () => { // Stop any in-flight async validation or listener work tied to this instance. - for (const key of Object.keys( + for (const [key, timeout] of Object.entries( this.timeoutIds.validations, - ) as ValidationCause[]) { - const timeout = this.timeoutIds.validations[key] + )) { if (timeout) { clearTimeout(timeout) - this.timeoutIds.validations[key] = null + this.timeoutIds.validations[ + key as keyof typeof this.timeoutIds.validations + ] = null } } - for (const key of Object.keys( - this.timeoutIds.listeners, - ) as ListenerCause[]) { - const timeout = this.timeoutIds.listeners[key] + for (const [key, timeout] of Object.entries(this.timeoutIds.listeners)) { if (timeout) { clearTimeout(timeout) - this.timeoutIds.listeners[key] = null + this.timeoutIds.listeners[ + key as keyof typeof this.timeoutIds.listeners + ] = null } } - for (const key of Object.keys( + for (const [key, timeout] of Object.entries( this.timeoutIds.formListeners, - ) as ListenerCause[]) { - const timeout = this.timeoutIds.formListeners[key] + )) { if (timeout) { clearTimeout(timeout) - this.timeoutIds.formListeners[key] = null + this.timeoutIds.formListeners[ + key as keyof typeof this.timeoutIds.formListeners + ] = null } } const fieldInfo = this.form.fieldInfo[this.name] - for (const key of Object.keys(fieldInfo.validationMetaMap) as Array< - keyof typeof fieldInfo.validationMetaMap - >) { - fieldInfo.validationMetaMap[key]?.lastAbortController.abort() - fieldInfo.validationMetaMap[key] = undefined + for (const [key, validationMeta] of Object.entries( + fieldInfo.validationMetaMap, + )) { + validationMeta?.lastAbortController.abort() + fieldInfo.validationMetaMap[ + key as keyof typeof fieldInfo.validationMetaMap + ] = undefined } // If a newer field instance has already been mounted for this name, From 7dadaa7cc6efd5154662cf196d85a9bb615505a6 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 6 Mar 2026 13:23:58 +0100 Subject: [PATCH 04/12] jsdoc --- packages/form-core/src/FieldApi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 9defe76c3..2ee43eb82 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1275,6 +1275,7 @@ export class FieldApi< /** * Mounts the field instance to the form. + * @returns A function to unmount the field instance. */ mount = () => { if (this.options.defaultValue !== undefined && !this.getMeta().isTouched) { From 8e9635ccc376ae5bd771bc413d8faee3e7bbdd63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Fri, 6 Mar 2026 13:26:08 +0100 Subject: [PATCH 05/12] Fix field unmount issue in core Fixes the issue with field unmount in core. --- .changeset/red-hats-jam.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/red-hats-jam.md diff --git a/.changeset/red-hats-jam.md b/.changeset/red-hats-jam.md new file mode 100644 index 000000000..d5449ea4f --- /dev/null +++ b/.changeset/red-hats-jam.md @@ -0,0 +1,6 @@ +--- +"@tanstack/form-core": patch +"@tanstack/react-form": patch +--- + +fix(core): field unmount From 9f7489e49fa669aa35366a15e07f86285f509404 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:27:02 +0000 Subject: [PATCH 06/12] ci: apply automated fixes and generate docs --- .changeset/red-hats-jam.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/red-hats-jam.md b/.changeset/red-hats-jam.md index d5449ea4f..86caeedf3 100644 --- a/.changeset/red-hats-jam.md +++ b/.changeset/red-hats-jam.md @@ -1,6 +1,6 @@ --- -"@tanstack/form-core": patch -"@tanstack/react-form": patch +'@tanstack/form-core': patch +'@tanstack/react-form': patch --- fix(core): field unmount From b411f59cedca18af483f70043fb8b231336db21c Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 6 Mar 2026 13:45:01 +0100 Subject: [PATCH 07/12] fix: update fieldInfo type to be partial and handle unmounted fields --- packages/form-core/src/FieldApi.ts | 1 + packages/form-core/src/FormApi.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 2ee43eb82..c87b13688 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1355,6 +1355,7 @@ export class FieldApi< } const fieldInfo = this.form.fieldInfo[this.name] + if (!fieldInfo) return for (const [key, validationMeta] of Object.entries( fieldInfo.validationMetaMap, diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index d78c39a8a..010dccaa7 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -925,7 +925,7 @@ export class FormApi< /** * A record of field information for each field in the form. */ - fieldInfo: Record, FieldInfo> = {} as any + fieldInfo: Partial, FieldInfo>> = {} get state() { return this.store.state @@ -1603,7 +1603,6 @@ export class FormApi< field: TField, cause: ValidationCause, ) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const fieldInstance = this.fieldInfo[field]?.instance if (!fieldInstance) { @@ -2222,7 +2221,6 @@ export class FormApi< getFieldInfo = >( field: TField, ): FieldInfo => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return (this.fieldInfo[field] ||= { instance: null, validationMetaMap: { From 4eff6fa18e46c1e175822a14fc3a1883af696a6f Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 13 Mar 2026 19:32:56 +0100 Subject: [PATCH 08/12] fix: cleanupFieldsOnUnmount flag --- packages/form-core/src/FieldApi.ts | 4 ++ packages/form-core/src/FormApi.ts | 5 +++ packages/form-core/tests/FieldApi.spec.ts | 45 +++++++++++++++++++-- packages/react-form/tests/useField.test.tsx | 1 + 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index c87b13688..fefd1f224 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1323,6 +1323,10 @@ export class FieldApi< fieldApi: this, }) + if (!this.form.options.cleanupFieldsOnUnmount) { + return () => {} + } + return () => { // Stop any in-flight async validation or listener work tied to this instance. for (const [key, timeout] of Object.entries( diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 010dccaa7..e043a4db1 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -373,6 +373,11 @@ export interface FormOptions< * If true, allows the form to be submitted in an invalid state i.e. canSubmit will remain true regardless of validation errors. Defaults to undefined. */ canSubmitWhenInvalid?: boolean + /** + * If true, mounted fields clean up their validation state when they unmount. + * Defaults to false. + */ + cleanupFieldsOnUnmount?: boolean /** * A list of validators to pass to the form */ diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 3179a9286..8de539a0f 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1603,12 +1603,42 @@ describe('field api', () => { expect(form.getFieldInfo(field.name)).toBeDefined() }) + it('should keep field meta on unmount by default', async () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onSubmit: ({ value }) => + value.length > 0 ? undefined : 'name is required', + }, + }) + + const unmount = field.mount() + + await form.handleSubmit() + expect(form.state.fieldMeta.name?.errors).toContain('name is required') + + expect(unmount).toBeTypeOf('function') + unmount() + expect(form.state.fieldMeta.name?.errors).toContain('name is required') + expect(form.state.canSubmit).toBe(false) + }) + it('should clear meta on unmount while preserving value', async () => { const form = new FormApi({ defaultValues: { firstName: 'a', lastName: 'abc', }, + cleanupFieldsOnUnmount: true, onSubmit: () => {}, }) @@ -1636,7 +1666,8 @@ describe('field api', () => { 'last name must be at least 5 chars', ) - unmountLastName() + expect(unmountLastName).toBeTypeOf('function') + unmountLastName?.() expect(form.getFieldValue('lastName')).toBe('abc') expect(form.state.fieldMeta.lastName).toMatchObject({ @@ -1673,6 +1704,7 @@ describe('field api', () => { defaultValues: { name: '', }, + cleanupFieldsOnUnmount: true, }) form.mount() @@ -1694,7 +1726,8 @@ describe('field api', () => { field.setValue('trigger') await vi.runAllTimersAsync() - unmount() + expect(unmount).toBeTypeOf('function') + unmount?.() resolveValidation() await vi.runAllTimersAsync() @@ -1717,6 +1750,7 @@ describe('field api', () => { defaultValues: { name: '', }, + cleanupFieldsOnUnmount: true, listeners: { onChange: formListener, onChangeDebounceMs: 200, @@ -1736,7 +1770,8 @@ describe('field api', () => { const unmount = field.mount() field.setValue('trigger') - unmount() + expect(unmount).toBeTypeOf('function') + unmount?.() await vi.advanceTimersByTimeAsync(500) @@ -1751,6 +1786,7 @@ describe('field api', () => { defaultValues: { name: '', }, + cleanupFieldsOnUnmount: true, }) form.mount() @@ -1768,7 +1804,8 @@ describe('field api', () => { newField.mount() newField.setValue('new value') - oldUnmount() + expect(oldUnmount).toBeTypeOf('function') + oldUnmount?.() expect(form.getFieldInfo('name').instance).toBe(newField) expect(newField.getValue()).toBe('new value') diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index 45996ce97..344d6a35a 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -337,6 +337,7 @@ describe('useField', () => { firstName: '', lastName: '', }, + cleanupFieldsOnUnmount: true, onSubmit: ({ value }) => onSubmit(value), }) From 10abde08134ef961a2956f856828f0107c348ab5 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 13 Mar 2026 19:39:39 +0100 Subject: [PATCH 09/12] fix: eslint --- packages/form-core/tests/FieldApi.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 8de539a0f..26ddcf51c 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1667,7 +1667,7 @@ describe('field api', () => { ) expect(unmountLastName).toBeTypeOf('function') - unmountLastName?.() + unmountLastName() expect(form.getFieldValue('lastName')).toBe('abc') expect(form.state.fieldMeta.lastName).toMatchObject({ @@ -1727,7 +1727,7 @@ describe('field api', () => { await vi.runAllTimersAsync() expect(unmount).toBeTypeOf('function') - unmount?.() + unmount() resolveValidation() await vi.runAllTimersAsync() @@ -1771,7 +1771,7 @@ describe('field api', () => { const unmount = field.mount() field.setValue('trigger') expect(unmount).toBeTypeOf('function') - unmount?.() + unmount() await vi.advanceTimersByTimeAsync(500) @@ -1805,7 +1805,7 @@ describe('field api', () => { newField.setValue('new value') expect(oldUnmount).toBeTypeOf('function') - oldUnmount?.() + oldUnmount() expect(form.getFieldInfo('name').instance).toBe(newField) expect(newField.getValue()).toBe('new value') From 8b4148746495e233eb333baf68b8137ffadca1ba Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 13 Mar 2026 20:22:24 +0100 Subject: [PATCH 10/12] fix: coderabbit findings --- packages/form-core/src/FieldApi.ts | 23 +++++-- packages/form-core/tests/FieldApi.spec.ts | 76 ++++++++++++++++++++- packages/react-form/tests/useField.test.tsx | 2 +- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index fefd1f224..7ab115885 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1361,6 +1361,10 @@ export class FieldApi< const fieldInfo = this.form.fieldInfo[this.name] if (!fieldInfo) return + // If a newer field instance has already been mounted for this name, + // avoid touching its shared validation state during teardown. + if (fieldInfo.instance !== this) return + for (const [key, validationMeta] of Object.entries( fieldInfo.validationMetaMap, )) { @@ -1370,15 +1374,24 @@ export class FieldApi< ] = undefined } - // If a newer field instance has already been mounted for this name, - // avoid clearing its state during teardown of an older instance. - if (fieldInfo.instance !== this) return - this.form.baseStore.setState((prev) => ({ + // Preserve interaction flags so field-level defaultValue does not + // reseed user-entered values on remount. ...prev, fieldMetaBase: { ...prev.fieldMetaBase, - [this.name]: defaultFieldMeta, + [this.name]: { + ...defaultFieldMeta, + isTouched: + prev.fieldMetaBase[this.name]?.isTouched ?? + defaultFieldMeta.isTouched, + isBlurred: + prev.fieldMetaBase[this.name]?.isBlurred ?? + defaultFieldMeta.isBlurred, + isDirty: + prev.fieldMetaBase[this.name]?.isDirty ?? + defaultFieldMeta.isDirty, + }, }, })) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 26ddcf51c..7a4a95c3b 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1671,7 +1671,7 @@ describe('field api', () => { expect(form.getFieldValue('lastName')).toBe('abc') expect(form.state.fieldMeta.lastName).toMatchObject({ - isTouched: false, + isTouched: true, isValid: true, errors: [], }) @@ -1688,10 +1688,39 @@ describe('field api', () => { remountedLastName.mount() expect(remountedLastName.getMeta().errors).toStrictEqual([]) - expect(remountedLastName.getMeta().isTouched).toBe(false) + expect(remountedLastName.getMeta().isTouched).toBe(true) expect(remountedLastName.getValue()).toBe('abc') }) + it('should preserve field-level defaultValue changes across unmount remount cleanup', () => { + const form = new FormApi({ + defaultValues: {} as { name?: string }, + cleanupFieldsOnUnmount: true, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + defaultValue: 'initial', + }) + + const unmount = field.mount() + field.setValue('changed') + expect(unmount).toBeTypeOf('function') + unmount() + + const remountedField = new FieldApi({ + form, + name: 'name', + defaultValue: 'initial', + }) + + remountedField.mount() + expect(remountedField.getValue()).toBe('changed') + }) + it('should not apply in-flight async validation results after unmount', async () => { vi.useFakeTimers() @@ -1732,7 +1761,7 @@ describe('field api', () => { await vi.runAllTimersAsync() expect(form.state.fieldMeta.name).toMatchObject({ - isTouched: false, + isTouched: true, isValid: true, errors: [], }) @@ -1812,6 +1841,47 @@ describe('field api', () => { expect(newField.getMeta().isTouched).toBe(true) }) + it('should not cancel newer instance async validation when older instance unmounts', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: '', + }, + cleanupFieldsOnUnmount: true, + }) + + form.mount() + + const oldField = new FieldApi({ + form, + name: 'name', + }) + const oldUnmount = oldField.mount() + + const newField = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsyncDebounceMs: 10, + onChangeAsync: async ({ value }) => + value === 'taken' ? 'name is taken' : undefined, + }, + }) + + newField.mount() + newField.setValue('taken') + + expect(oldUnmount).toBeTypeOf('function') + oldUnmount() + + await vi.runAllTimersAsync() + + expect(newField.getMeta().errors).toContain('name is taken') + + vi.useRealTimers() + }) + it('should show onSubmit errors', async () => { const form = new FormApi({ defaultValues: { diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index 344d6a35a..9d74bf2e3 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -420,7 +420,7 @@ describe('useField', () => { await waitFor(() => expect(submitButton).toBeEnabled()) await user.click(submitButton) - expect(onSubmit).toHaveBeenCalledTimes(1) + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) await user.clear(getByTestId('first-name')) await user.type(getByTestId('first-name'), 'a') From 09307f6b92ba65b94dba997bec8837dd85032b16 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 14 Mar 2026 09:02:53 +0100 Subject: [PATCH 11/12] fix: ensure async validation cancellation on field unmount --- packages/form-core/src/FieldApi.ts | 19 +++++--- packages/form-core/tests/FieldApi.spec.ts | 55 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 7ab115885..6404ac7b8 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1863,12 +1863,13 @@ export class FieldApi< promises: Promise[], ) => { const errorMapKey = getErrorMapKey(validateObj.cause) - const fieldValidatorMeta = field.getInfo().validationMetaMap[errorMapKey] + const fieldInfo = field.getInfo() + const fieldValidatorMeta = fieldInfo.validationMetaMap[errorMapKey] fieldValidatorMeta?.lastAbortController.abort() const controller = new AbortController() - this.getInfo().validationMetaMap[errorMapKey] = { + fieldInfo.validationMetaMap[errorMapKey] = { lastAbortController: controller, } @@ -1877,11 +1878,11 @@ export class FieldApi< let rawError!: ValidationError | undefined try { rawError = await new Promise((rawResolve, rawReject) => { - if (this.timeoutIds.validations[validateObj.cause]) { - clearTimeout(this.timeoutIds.validations[validateObj.cause]!) + if (field.timeoutIds.validations[validateObj.cause]) { + clearTimeout(field.timeoutIds.validations[validateObj.cause]!) } - this.timeoutIds.validations[validateObj.cause] = setTimeout( + field.timeoutIds.validations[validateObj.cause] = setTimeout( async () => { if (controller.signal.aborted) return rawResolve(undefined) try { @@ -1911,7 +1912,9 @@ export class FieldApi< const fieldLevelError = normalizeError(rawError) const formLevelError = - asyncFormValidationResults[this.name]?.[errorMapKey] + asyncFormValidationResults[ + field.name as keyof typeof asyncFormValidationResults + ]?.[errorMapKey] const { newErrorValue, newSource } = determineFieldLevelErrorSourceAndValue({ @@ -1919,6 +1922,10 @@ export class FieldApi< fieldLevelError, }) + if (field.getInfo().instance !== field) { + return resolve(undefined) + } + field.setMeta((prev) => { return { ...prev, diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 7a4a95c3b..8ae1c916f 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2229,6 +2229,61 @@ describe('field api', () => { ]) }) + it('should cancel linked field async validation when the target field unmounts', async () => { + vi.useFakeTimers() + + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r as never + }) + + const form = new FormApi({ + defaultValues: { + password: '', + confirm_password: '', + }, + cleanupFieldsOnUnmount: true, + }) + + form.mount() + + const passField = new FieldApi({ + form, + name: 'password', + }) + + const passconfirmField = new FieldApi({ + form, + name: 'confirm_password', + validators: { + onChangeListenTo: ['password'], + onChangeAsyncDebounceMs: 0, + onChangeAsync: async () => { + await promise + return 'Passwords do not match' + }, + }, + }) + + passField.mount() + const unmount = passconfirmField.mount() + + passField.setValue('one') + await vi.runAllTimersAsync() + + expect(unmount).toBeTypeOf('function') + unmount() + resolve() + await vi.runAllTimersAsync() + + expect(form.state.fieldMeta.confirm_password).toMatchObject({ + errors: [], + isValid: true, + }) + + vi.useRealTimers() + }) + it('should add a new value to the fieldApi errorMap', () => { interface Form { name: string From 822d7afade399c07d74cabdaca77ee68a0dad0c1 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 14 Mar 2026 09:45:07 +0100 Subject: [PATCH 12/12] more tests --- packages/form-core/tests/FieldApi.spec.ts | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 8ae1c916f..f1bbc587a 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1810,6 +1810,28 @@ describe('field api', () => { vi.useRealTimers() }) + it('should ignore cleanup when fieldInfo was deleted before unmount', () => { + const form = new FormApi({ + defaultValues: { + name: '', + }, + cleanupFieldsOnUnmount: true, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + + const unmount = field.mount() + form.deleteField('name') + + expect(unmount).toBeTypeOf('function') + expect(() => unmount()).not.toThrow() + }) + it('should not clear newer instance state when older instance unmounts', () => { const form = new FormApi({ defaultValues: { @@ -1882,6 +1904,84 @@ describe('field api', () => { vi.useRealTimers() }) + it('should ignore stale async validation results from an older remounted instance', async () => { + vi.useFakeTimers() + + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r as never + }) + + const form = new FormApi({ + defaultValues: { + name: '', + }, + cleanupFieldsOnUnmount: true, + }) + + form.mount() + + const oldField = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsyncDebounceMs: 0, + onChangeAsync: async () => { + await promise + return 'stale error' + }, + }, + }) + + oldField.mount() + oldField.setValue('taken') + await vi.runAllTimersAsync() + + const newField = new FieldApi({ + form, + name: 'name', + }) + newField.mount() + + resolve() + await vi.runAllTimersAsync() + + expect(newField.getMeta().errors).toStrictEqual([]) + + vi.useRealTimers() + }) + + it('should surface thrown async validator errors', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsyncDebounceMs: 0, + onChangeAsync: async () => { + throw 'async validation failed' + }, + }, + }) + + field.mount() + field.setValue('test') + await vi.runAllTimersAsync() + + expect(field.getMeta().errors).toContain('async validation failed') + + vi.useRealTimers() + }) + it('should show onSubmit errors', async () => { const form = new FormApi({ defaultValues: {