From 2afa535c6ea5985d3a4ab1e37017e3298b03566d Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 6 Mar 2026 13:15:30 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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: {