From ba6de4a83142fafcb74d5763f7e6574067019b14 Mon Sep 17 00:00:00 2001 From: coi Date: Fri, 11 Sep 2026 18:14:44 +0900 Subject: [PATCH 1/4] test(form-core): reproduce validation counter race across reset --- packages/form-core/tests/FieldApi.spec.ts | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 7c9a468ab8..f2ee890e28 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2254,6 +2254,80 @@ describe('field api', () => { expect(fn).toHaveBeenCalledTimes(1) }) + it.each([ + { linked: false, reset: false }, + { linked: false, reset: true }, + { linked: true, reset: false }, + { linked: true, reset: true }, + ])( + 'should preserve pending validation when an older run settles (linked: $linked, reset: $reset)', + async ({ linked, reset }) => { + vi.useFakeTimers() + try { + const form = new FormApi({ + defaultValues: { source: '', email: '' }, + }) + form.mount() + + let resolveChange!: (error: string | undefined) => void + const changeResult = new Promise((resolve) => { + resolveChange = resolve + }) + let resolveBlur!: (error: string | undefined) => void + const blurResult = new Promise((resolve) => { + resolveBlur = resolve + }) + const onChangeAsync = vi.fn(async () => await changeResult) + const onBlurAsync = vi.fn(async () => await blurResult) + const source = new FieldApi({ form, name: 'source' }) + const field = new FieldApi({ + form, + name: 'email', + validators: { + onChangeListenTo: linked ? ['source'] : undefined, + onChangeAsync, + onBlurAsync, + }, + }) + source.mount() + field.mount() + + if (linked) source.setValue('old-input') + else field.setValue('old-input') + await vi.runAllTimersAsync() + expect(onChangeAsync).toHaveBeenCalledTimes(1) + expect(field.state.meta.isValidating).toBe(true) + + if (reset) { + form.reset() + expect(field.state.meta.isValidating).toBe(false) + expect(field.state.meta._pendingValidationsCount).toBe(0) + } + + field.handleBlur() + await vi.runAllTimersAsync() + expect(onBlurAsync).toHaveBeenCalledTimes(1) + expect(field.state.meta.isValidating).toBe(true) + expect(field.state.meta._pendingValidationsCount).toBe(reset ? 1 : 2) + + resolveChange(undefined) + await vi.runAllTimersAsync() + expect(field.state.meta.isValidating).toBe(true) + expect(field.state.meta._pendingValidationsCount).toBe(1) + expect(form.state.isFieldsValidating).toBe(true) + + resolveBlur('New validation error') + await vi.runAllTimersAsync() + expect(field.state.meta.errorMap.onBlur).toBe('New validation error') + expect(field.state.meta.isValidating).toBe(false) + expect(field.state.meta._pendingValidationsCount).toBe(0) + expect(form.state.isFieldsValidating).toBe(false) + } finally { + vi.useRealTimers() + } + }, + ) + it('should run onChange on a linked field', () => { const form = new FormApi({ defaultValues: { From 7fbbc46dcb2f0801a62ff480800e352ca70a7362 Mon Sep 17 00:00:00 2001 From: coi Date: Fri, 11 Sep 2026 18:17:44 +0900 Subject: [PATCH 2/4] fix(form-core): preserve validation counters across form reset --- .changeset/tidy-validation-counters.md | 5 +++++ packages/form-core/src/FieldApi.ts | 4 ++++ packages/form-core/src/FormApi.ts | 5 +++++ 3 files changed, 14 insertions(+) create mode 100644 .changeset/tidy-validation-counters.md diff --git a/.changeset/tidy-validation-counters.md b/.changeset/tidy-validation-counters.md new file mode 100644 index 0000000000..651142e5aa --- /dev/null +++ b/.changeset/tidy-validation-counters.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Prevent field validations started before a form reset from clearing the validation status of newer runs when they finish. diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 80770f8897..5542a64c48 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1468,6 +1468,7 @@ export class FieldApi< // Check if there are actual async validators to run before setting isValidating // This prevents unnecessary re-renders when there are no async validators // See: https://github.com/TanStack/form/issues/1130 + const validationGeneration = this.form._validationGeneration const hasAsyncValidators = validates.some((v) => v.validate) const linkedFieldsWithAsyncValidators = Array.from( new Set( @@ -1597,6 +1598,9 @@ export class FieldApi< // Only reset isValidating if we set it to true earlier batch(() => { + // Reset replaces the counters; an older run no longer owns a decrement. + if (validationGeneration !== this.form._validationGeneration) return + if (hasAsyncValidators) { this.endValidation() } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 4400017d30..c1ea884042 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1062,6 +1062,10 @@ export class FormApi< * @private */ _formId: string + /** + * @private + */ + _validationGeneration = 0 /** * @private */ @@ -1808,6 +1812,7 @@ export class FormApi< * @param opts - Optional options to control the reset behavior. */ reset = (values?: TFormData, opts?: { keepDefaultValues?: boolean }) => { + this._validationGeneration++ const { fieldMeta: currentFieldMeta } = this.state const fieldMetaBase = this.resetFieldMeta(currentFieldMeta) From 80628c6982df98a49019b6b4ca2995295fd7f544 Mon Sep 17 00:00:00 2001 From: coi Date: Fri, 11 Sep 2026 18:45:58 +0900 Subject: [PATCH 3/4] fix(form-core): preserve counters when canceling pre-reset debounce --- .changeset/tidy-validation-counters.md | 2 +- packages/form-core/src/FieldApi.ts | 69 +++++++++++++++-------- packages/form-core/tests/FieldApi.spec.ts | 62 ++++++++++++++++++++ 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/.changeset/tidy-validation-counters.md b/.changeset/tidy-validation-counters.md index 651142e5aa..c790d58557 100644 --- a/.changeset/tidy-validation-counters.md +++ b/.changeset/tidy-validation-counters.md @@ -2,4 +2,4 @@ '@tanstack/form-core': patch --- -Prevent field validations started before a form reset from clearing the validation status of newer runs when they finish. +Prevent field validations started before a form reset from clearing the validation status of newer runs when they finish or their pending debounce is canceled. diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 5542a64c48..4fcabf0348 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -706,6 +706,13 @@ export class FieldApi< formListeners: Record | null> } + private validationTimeouts: Partial< + Record< + ValidationCause, + { id: ReturnType; generation: number } + > + > = {} + /** * Initializes a new `FieldApi` instance. */ @@ -1507,33 +1514,45 @@ export class FieldApi< let rawError!: ValidationError | undefined try { rawError = await new Promise((rawResolve, rawReject) => { - if (field.timeoutIds.validations[validateObj.cause]) { - clearTimeout(field.timeoutIds.validations[validateObj.cause]!) - field.endValidation() + const previousTimeout = + field.timeoutIds.validations[validateObj.cause] + if (previousTimeout) { + clearTimeout(previousTimeout) + const previousValidation = + field.validationTimeouts[validateObj.cause] + // Only a timer owned by the pre-reset run loses its decrement. + if ( + previousValidation?.id !== previousTimeout || + previousValidation.generation === validationGeneration + ) { + field.endValidation() + } } - field.timeoutIds.validations[validateObj.cause] = setTimeout( - async () => { - if (controller.signal.aborted) return rawResolve(undefined) - try { - rawResolve( - await this.runValidator({ - validate: validateObj.validate, - value: { - value: field.store.state.value, - fieldApi: field, - signal: controller.signal, - validationSource: 'field', - }, - type: 'validateAsync', - }), - ) - } catch (e) { - rawReject(e) - } - }, - validateObj.debounceMs, - ) + const timeoutId = setTimeout(async () => { + if (controller.signal.aborted) return rawResolve(undefined) + try { + rawResolve( + await this.runValidator({ + validate: validateObj.validate, + value: { + value: field.store.state.value, + fieldApi: field, + signal: controller.signal, + validationSource: 'field', + }, + type: 'validateAsync', + }), + ) + } catch (e) { + rawReject(e) + } + }, validateObj.debounceMs) + field.timeoutIds.validations[validateObj.cause] = timeoutId + field.validationTimeouts[validateObj.cause] = { + id: timeoutId, + generation: validationGeneration, + } }) } catch (e: unknown) { rawError = e as ValidationError diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index f2ee890e28..327fb2ebda 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2328,6 +2328,68 @@ describe('field api', () => { }, ) + it.each([ + { linked: false, reset: false }, + { linked: false, reset: true }, + { linked: true, reset: false }, + { linked: true, reset: true }, + ])( + 'should preserve pending validation when replacing a debounce (linked: $linked, reset: $reset)', + async ({ linked, reset }) => { + vi.useFakeTimers() + try { + const form = new FormApi({ + defaultValues: { source: '', email: '' }, + }) + form.mount() + let resolveValidation!: (error: string | undefined) => void + const result = new Promise((resolve) => { + resolveValidation = resolve + }) + const onChangeAsync = vi.fn(async () => await result) + const source = new FieldApi({ form, name: 'source' }) + const field = new FieldApi({ + form, + name: 'email', + validators: { + onChangeListenTo: linked ? ['source'] : undefined, + onChangeAsyncDebounceMs: 1000, + onChangeAsync, + }, + }) + source.mount() + field.mount() + + if (linked) source.setValue('first') + else field.setValue('first') + await vi.advanceTimersByTimeAsync(0) + expect(onChangeAsync).not.toHaveBeenCalled() + expect(field.state.meta._pendingValidationsCount).toBe(1) + + if (reset) form.reset() + if (linked) source.setValue('second') + else field.setValue('second') + await vi.advanceTimersByTimeAsync(0) + expect(onChangeAsync).not.toHaveBeenCalled() + expect(field.state.meta.isValidating).toBe(true) + expect(field.state.meta._pendingValidationsCount).toBe(1) + + await vi.advanceTimersByTimeAsync(1000) + expect(onChangeAsync).toHaveBeenCalledTimes(1) + expect(field.state.meta.isValidating).toBe(true) + expect(field.state.meta._pendingValidationsCount).toBe(1) + + resolveValidation('New validation error') + await vi.runAllTimersAsync() + expect(field.state.meta.errorMap.onChange).toBe('New validation error') + expect(field.state.meta.isValidating).toBe(false) + expect(field.state.meta._pendingValidationsCount).toBe(0) + } finally { + vi.useRealTimers() + } + }, + ) + it('should run onChange on a linked field', () => { const form = new FormApi({ defaultValues: { From 1eef92ad44c2f3572e9dea5631e4ff4f26de3f84 Mon Sep 17 00:00:00 2001 From: coi Date: Fri, 11 Sep 2026 19:03:46 +0900 Subject: [PATCH 4/4] fix(form-core): discard stale field work after awaiting form validation --- .changeset/tidy-validation-counters.md | 2 +- packages/form-core/src/FieldApi.ts | 3 +- packages/form-core/tests/FieldApi.spec.ts | 91 +++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/.changeset/tidy-validation-counters.md b/.changeset/tidy-validation-counters.md index c790d58557..ca1583e1af 100644 --- a/.changeset/tidy-validation-counters.md +++ b/.changeset/tidy-validation-counters.md @@ -2,4 +2,4 @@ '@tanstack/form-core': patch --- -Prevent field validations started before a form reset from clearing the validation status of newer runs when they finish or their pending debounce is canceled. +Preserve field validation state across form resets by discarding stale work waiting for form validation and preventing older completions or debounce cancellations from decrementing current validation counters. diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 4fcabf0348..a8f90ec58b 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1432,6 +1432,7 @@ export class FieldApi< > >, ) => { + const validationGeneration = this.form._validationGeneration const validates = getAsyncValidatorArray(cause, { ...this.options, form: this.form, @@ -1442,6 +1443,7 @@ export class FieldApi< // Get the field-specific error messages that are coming from the form's validator const asyncFormValidationResults = await formValidationResultPromise + if (validationGeneration !== this.form._validationGeneration) return [] const linkedFields = this.getLinkedFields(cause) const linkedFieldValidates = linkedFields.reduce( @@ -1475,7 +1477,6 @@ export class FieldApi< // Check if there are actual async validators to run before setting isValidating // This prevents unnecessary re-renders when there are no async validators // See: https://github.com/TanStack/form/issues/1130 - const validationGeneration = this.form._validationGeneration const hasAsyncValidators = validates.some((v) => v.validate) const linkedFieldsWithAsyncValidators = Array.from( new Set( diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 327fb2ebda..3ca0e237e2 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2390,6 +2390,97 @@ describe('field api', () => { }, ) + it.each([ + { linked: false, mode: 'no reset' }, + { linked: true, mode: 'no reset' }, + { linked: false, mode: 'reset only' }, + { linked: true, mode: 'reset only' }, + { linked: false, mode: 'reset and new validation' }, + { linked: true, mode: 'reset and new validation' }, + ])( + 'should discard stale work awaiting form validation (linked: $linked, mode: $mode)', + async ({ linked, mode }) => { + vi.useFakeTimers() + try { + let resolveForm!: (result: undefined) => void + const formResult = new Promise((resolve) => { + resolveForm = resolve + }) + let formCalls = 0 + const form = new FormApi({ + defaultValues: { source: '', email: '' }, + validators: { + onChangeAsync: async () => { + formCalls++ + return formCalls === 1 ? await formResult : undefined + }, + }, + }) + form.mount() + + let resolveField!: (error: string | undefined) => void + const fieldResult = new Promise((resolve) => { + resolveField = resolve + }) + const onChangeAsync = vi.fn(async () => await fieldResult) + const source = new FieldApi({ form, name: 'source' }) + const field = new FieldApi({ + form, + name: 'email', + validators: { + onChangeListenTo: linked ? ['source'] : undefined, + onChangeAsync, + }, + }) + source.mount() + field.mount() + + if (linked) source.setValue('old-input') + else field.setValue('old-input') + await vi.runAllTimersAsync() + expect(formCalls).toBe(1) + expect(onChangeAsync).not.toHaveBeenCalled() + + if (mode !== 'no reset') form.reset() + if (mode === 'reset and new validation') { + if (linked) source.setValue('new-input') + else field.setValue('new-input') + await vi.runAllTimersAsync() + expect(formCalls).toBe(2) + expect(onChangeAsync).toHaveBeenCalledTimes(1) + expect(field.state.meta.isValidating).toBe(true) + } + const newController = + field.getInfo().validationMetaMap.onChange?.lastAbortController + + resolveForm(undefined) + await vi.runAllTimersAsync() + expect(onChangeAsync).toHaveBeenCalledTimes( + mode === 'reset only' ? 0 : 1, + ) + expect(field.state.meta._pendingValidationsCount).toBe( + mode === 'reset only' ? 0 : 1, + ) + if (mode === 'reset and new validation') { + expect(newController?.signal.aborted).toBe(false) + expect( + field.getInfo().validationMetaMap.onChange?.lastAbortController, + ).toBe(newController) + } + + resolveField('Field error') + await vi.runAllTimersAsync() + expect(field.state.meta.isValidating).toBe(false) + expect(field.state.meta._pendingValidationsCount).toBe(0) + expect(field.state.meta.errorMap.onChange).toBe( + mode === 'reset only' ? undefined : 'Field error', + ) + } finally { + vi.useRealTimers() + } + }, + ) + it('should run onChange on a linked field', () => { const form = new FormApi({ defaultValues: {