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()