From ee4435db942b3c55c38c075f8b49c2e8fe9fa0b6 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sat, 5 Sep 2026 16:05:40 +0000 Subject: [PATCH] fix(form-core): report isValidating on every async validation run The debounce timer id stored in `timeoutIds.validations` was never released once the timer fired. The next async run read that stale id as "a previous run is still pending" and ran the compensating `endValidation()`, which decremented the pending-validation counter for a run that had already finished. `isValidating` therefore dropped back to false in the same tick the second run started, and stayed false for the rest of that run. Release the id inside the timer callback so the compensation only applies to a run that is genuinely still waiting on its debounce. Fixes #2372 --- .changeset/wild-moons-repeat.md | 5 +++ packages/form-core/src/FieldApi.ts | 8 +++++ packages/form-core/tests/FieldApi.spec.ts | 44 +++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 .changeset/wild-moons-repeat.md diff --git a/.changeset/wild-moons-repeat.md b/.changeset/wild-moons-repeat.md new file mode 100644 index 0000000000..8227c4a7d7 --- /dev/null +++ b/.changeset/wild-moons-repeat.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Report `isValidating` on every async validation run instead of only the first one. diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 80770f8897..4f01e011fe 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1513,6 +1513,14 @@ export class FieldApi< field.timeoutIds.validations[validateObj.cause] = setTimeout( async () => { + // The timer has fired, so this run is no longer waiting on a + // debounce. Release the id right away: the branch above uses a + // stored id to mean "a previous run is still pending" and + // compensates for it with `endValidation()`. Leaving a fired id + // behind makes the next run decrement the pending-validation + // counter for a run that already finished, which clears + // `isValidating` while that next run is still in flight. + field.timeoutIds.validations[validateObj.cause] = null if (controller.signal.aborted) return rawResolve(undefined) try { rawResolve( diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 7c9a468ab8..f44bc19e10 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -892,6 +892,50 @@ describe('field api', () => { storeunsub() }) + it('should set isValidating again on every async validation run after the first', async () => { + // Test for https://github.com/TanStack/form/issues/2372 + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: '', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onChangeAsync: async ({ value }) => { + await sleep(1000) + if (value === 'admin') return 'Username is already taken' + return + }, + }, + }) + + field.mount() + + // First run: isValidating is reported while the validator is pending. + field.setValue('admin') + await vi.advanceTimersByTimeAsync(0) + expect(field.getMeta().isValidating).toBe(true) + await vi.runAllTimersAsync() + expect(field.getMeta().isValidating).toBe(false) + expect(field.getMeta().errors).toContain('Username is already taken') + + // Second run: the validator is pending again, so isValidating must be + // reported again instead of staying false for the whole run. + field.setValue('asdf') + await vi.advanceTimersByTimeAsync(0) + expect(field.getMeta().isValidating).toBe(true) + await vi.runAllTimersAsync() + expect(field.getMeta().isValidating).toBe(false) + expect(field.getMeta().errors.length).toBe(0) + }) + it('should run async validation onChange', async () => { vi.useFakeTimers()