From 73f777c611db50f97c4df37f112b0bdecadad407 Mon Sep 17 00:00:00 2001 From: coi Date: Fri, 11 Sep 2026 17:33:34 +0900 Subject: [PATCH 1/2] test(form-core): reproduce stale field errors after reset --- packages/form-core/tests/FieldApi.spec.ts | 147 ++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 7c9a468ab8..3330b2bd85 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2254,6 +2254,153 @@ describe('field api', () => { expect(fn).toHaveBeenCalledTimes(1) }) + it('(control) should apply an onChangeAsync error once the validator settles, when no reset occurs', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + email: '', + }, + }) + form.mount() + + let resolveValidation!: (error: string | undefined) => void + const pendingValidation = new Promise((resolve) => { + resolveValidation = resolve + }) + + const field = new FieldApi({ + form, + name: 'email', + validators: { + onChangeAsyncDebounceMs: 0, + onChangeAsync: async () => await pendingValidation, + }, + }) + + field.mount() + + field.setValue('not-an-email') + await vi.runAllTimersAsync() + + resolveValidation('not-an-email is not valid') + await vi.runAllTimersAsync() + + expect(field.state.meta.errorMap.onChange).toBe('not-an-email is not valid') + expect(field.state.meta.errors).toStrictEqual(['not-an-email is not valid']) + + vi.useRealTimers() + }) + + it('should not reapply a stale onChangeAsync error after form.reset() once a validation started before reset settles', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + email: '', + }, + }) + form.mount() + + let receivedValue: string | undefined + let resolveValidation!: (error: string | undefined) => void + const pendingValidation = new Promise((resolve) => { + resolveValidation = resolve + }) + + const field = new FieldApi({ + form, + name: 'email', + validators: { + onChangeAsyncDebounceMs: 0, + onChangeAsync: async ({ value }) => { + receivedValue = value + return await pendingValidation + }, + }, + }) + + field.mount() + + field.setValue('not-an-email') + await vi.runAllTimersAsync() + + expect(receivedValue).toBe('not-an-email') + expect(field.state.meta.isValidating).toBe(true) + + form.reset() + + expect(form.state.values.email).toBe('') + expect(field.state.meta.errorMap.onChange).toBeUndefined() + expect(field.state.meta.errors).toStrictEqual([]) + + resolveValidation('not-an-email is not valid') + await vi.runAllTimersAsync() + + expect(form.state.values.email).toBe('') + expect(field.state.meta.errorMap.onChange).toBeUndefined() + expect(field.state.meta.errors).toStrictEqual([]) + + vi.useRealTimers() + }) + + it('should not let a stale pre-reset validation affect a new validation started after reset', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + email: '', + }, + }) + form.mount() + + let resolveA!: (error: string | undefined) => void + const pendingA = new Promise((resolve) => { + resolveA = resolve + }) + let resolveB!: (error: string | undefined) => void + let pendingB: Promise | undefined + + let callCount = 0 + const field = new FieldApi({ + form, + name: 'email', + validators: { + onChangeAsyncDebounceMs: 0, + onChangeAsync: async () => { + callCount += 1 + if (callCount === 1) return await pendingA + pendingB = new Promise((resolve) => { + resolveB = resolve + }) + return await pendingB + }, + }, + }) + + field.mount() + + field.setValue('bad-a') + await vi.runAllTimersAsync() + expect(field.state.meta.isValidating).toBe(true) + + form.reset() + + field.setValue('bad-b') + await vi.runAllTimersAsync() + expect(callCount).toBe(2) + + resolveA('A is invalid') + await vi.runAllTimersAsync() + expect(field.state.meta.errorMap.onChange).toBeUndefined() + + resolveB('B is invalid') + await vi.runAllTimersAsync() + expect(field.state.meta.errorMap.onChange).toBe('B is invalid') + + vi.useRealTimers() + }) + it('should run onChange on a linked field', () => { const form = new FormApi({ defaultValues: { From 0bcaae126a331f7420611e50e265a8d47e9ca7f8 Mon Sep 17 00:00:00 2001 From: coi Date: Fri, 11 Sep 2026 17:34:51 +0900 Subject: [PATCH 2/2] fix(form-core): abort pending field validations on reset --- .changeset/quiet-fields-reset.md | 5 +++++ packages/form-core/src/FormApi.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 .changeset/quiet-fields-reset.md diff --git a/.changeset/quiet-fields-reset.md b/.changeset/quiet-fields-reset.md new file mode 100644 index 0000000000..6297e731f8 --- /dev/null +++ b/.changeset/quiet-fields-reset.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Abort pending field validations when resetting the form so results for pre-reset values cannot reapply stale field errors. diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 4400017d30..3fa374068f 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1808,6 +1808,20 @@ export class FormApi< * @param opts - Optional options to control the reset behavior. */ reset = (values?: TFormData, opts?: { keepDefaultValues?: boolean }) => { + // Cancel any field validations still in flight so a result that resolves + // after this reset can't reapply a stale error for the pre-reset value. + ;(Object.values(this.fieldInfo) as FieldInfo[]).forEach( + (fieldInfo) => { + ;( + Object.values(fieldInfo.validationMetaMap) as Array< + ValidationMeta | undefined + > + ).forEach((validationMeta) => { + validationMeta?.lastAbortController.abort() + }) + }, + ) + const { fieldMeta: currentFieldMeta } = this.state const fieldMetaBase = this.resetFieldMeta(currentFieldMeta)