diff --git a/.changeset/red-hats-jam.md b/.changeset/red-hats-jam.md new file mode 100644 index 000000000..86caeedf3 --- /dev/null +++ b/.changeset/red-hats-jam.md @@ -0,0 +1,6 @@ +--- +'@tanstack/form-core': patch +'@tanstack/react-form': patch +--- + +fix(core): field unmount diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index f8b1b009e..c87b13688 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) { @@ -1322,8 +1323,63 @@ 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, timeout] of Object.entries( + this.timeoutIds.validations, + )) { + if (timeout) { + clearTimeout(timeout) + this.timeoutIds.validations[ + key as keyof typeof this.timeoutIds.validations + ] = null + } + } + for (const [key, timeout] of Object.entries(this.timeoutIds.listeners)) { + if (timeout) { + clearTimeout(timeout) + this.timeoutIds.listeners[ + key as keyof typeof this.timeoutIds.listeners + ] = null + } + } + for (const [key, timeout] of Object.entries( + this.timeoutIds.formListeners, + )) { + if (timeout) { + clearTimeout(timeout) + this.timeoutIds.formListeners[ + key as keyof typeof this.timeoutIds.formListeners + ] = null + } + } + + const fieldInfo = this.form.fieldInfo[this.name] + if (!fieldInfo) return + + 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, + // 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/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: { diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index f9d099022..3179a9286 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1603,6 +1603,178 @@ 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 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: { 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