Skip to content

fix(form-core): report isValidating on every async validation run - #2373

Open
ousamabenyounes wants to merge 1 commit into
TanStack:mainfrom
ousamabenyounes:fix/issue-2372
Open

fix(form-core): report isValidating on every async validation run#2373
ousamabenyounes wants to merge 1 commit into
TanStack:mainfrom
ousamabenyounes:fix/issue-2372

Conversation

@ousamabenyounes

@ousamabenyounes ousamabenyounes commented Sep 5, 2026

Copy link
Copy Markdown

🎯 Changes

Fixes #2372 — after the first async field validation completes, isValidating never becomes true again, so a "Checking…" indicator only ever shows once.

Root cause. In FieldApi.validateAsync, the debounce timer id is stored in timeoutIds.validations[cause] but is never released once the timer has fired. The branch above it treats a stored id as "a previous run is still pending" and compensates for that run with endValidation():

if (field.timeoutIds.validations[validateObj.cause]) {
  clearTimeout(field.timeoutIds.validations[validateObj.cause]!)
  field.endValidation()
}

That compensation is correct for a run whose timer is cleared before it fires: such a run's inner promise never settles, so its own final endValidation() never runs. Applied to an already-fired id it is an unbalanced decrement. The second run does startValidation() (_pendingValidationsCount 0 → 1, isValidating: true) and then immediately decrements back to 0 in the same tick, so isValidating reads false for the whole run.

Traced on main, isValidating / _pendingValidationsCount per store notification:

--- run 1 (setValue 'admin')
isValidating=true  count=1
isValidating=true  count=1
isValidating=false count=0      <- correct: true for the whole run
--- run 2 (setValue 'asdf')
isValidating=true  count=1
isValidating=false count=0      <- cleared immediately, run still in flight
isValidating=false count=0

Fix. Release the id inside the timer callback, so the compensation only applies to a run that is genuinely still waiting on its debounce. One statement in form-core; null is already the "no pending timer" sentinel used by the unmount cleanup for the same field.

One behaviour delta worth flagging

The stale-id branch was also, by accident, the only thing that decremented the counter for a validator that never settles and ignores its AbortSignal. I measured that case on both refs with onChangeAsync: async () => new Promise(() => {}):

scenario main this branch
one hanging validator, no second run isValidating: true (count 1) isValidating: true (count 1) — unchanged
hanging validator superseded by a second run isValidating: false (count 0) isValidating: true (count 1)

So "stuck validating on a validator that never resolves" is already main's behaviour in the single-run case; this PR does not introduce that mode. The only delta is the superseded case, where main reported false purely via the same unbalanced decrement that causes #2372 for well-behaved validators. Making aborted-but-unsettled runs settle would be a change to form-core's validation accounting well beyond this bug, so I left it alone — glad to follow up if you want it addressed.

Also out of scope

FormGroupApi writes into the same timeoutIds.validations slot for related fields and likewise never releases the id, so a group-driven async validation can leave a stale id that a later field-level run then compensates for. That path drives isValidating through direct setMeta calls and never touches _pendingValidationsCount, so it is a different accounting model, and I did not want to change it without a reproduction I could stand behind. Happy to extend this PR if you would like that path covered too.

Test verification (RED → GREEN)

New test: should set isValidating again on every async validation run after the first in packages/form-core/tests/FieldApi.spec.ts. It asserts the first run reports isValidating (which already works) and then the second run, so it cannot pass by disabling validation — it also asserts the error appears on run 1 and is gone on run 2.

RED — new test applied to unmodified main, no production change:

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 FAIL   form-core  tests/FieldApi.spec.ts > field api > should set isValidating again on every async validation run after the first
AssertionError: expected false to be true // Object.is equality
- Expected
+ Received
- true
+ false
 ❯ tests/FieldApi.spec.ts:933:42
    931|     field.setValue('asdf')
    932|     await vi.advanceTimersByTimeAsync(0)
    933|     expect(field.getMeta().isValidating).toBe(true)
       |                                          ^
    934|     await vi.runAllTimersAsync()
    935|     expect(field.getMeta().isValidating).toBe(false)

 Test Files  1 failed | 10 skipped (11)
      Tests  1 failed | 440 skipped (441)

GREEN — same test, unchanged, with the fix:

 ✓  form-core  tests/FieldApi.spec.ts (107 tests | 106 skipped) 26ms

 Test Files  1 passed | 10 skipped (11)
      Tests  1 passed | 440 skipped (441)

Full local validation

Ran the full local suite as pr.yml does — nx run-many --targets=test:sherif,test:knip,test:docs,test:eslint,test:lib,test:types,test:build,build over all 60 projects, then build:all, then a prettier --check pass — on the main baseline and on this branch. Both exit 0, with an identical lint-warning signature (0 errors on both).

package main baseline this branch
form-core 505 passed, 3 todo 506 passed, 3 todo
react-form 126 passed 126 passed
preact-form 104 passed 104 passed
solid-form 69 passed 69 passed
vue-form 32 passed 32 passed
angular-form 17 passed 17 passed
svelte-form 15 passed 15 passed
lit-form 10 passed 10 passed
react-form-start / -remix / -nextjs / devtools 3 / 2 / 2 / 3 passed 3 / 2 / 2 / 3 passed

No failures on either side; the only delta is the one test this PR adds.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed async validation status reporting so isValidating is set to true during every validation run, including repeated validations.
    • Prevented completed validation delays from affecting the tracking of pending validations.
  • Tests

    • Added coverage confirming validation status and error messages update correctly across repeated async validation runs.

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 TanStack#2372
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 632cb4b0-4392-49eb-9d10-99d85e2c17a1

📥 Commits

Reviewing files that changed from the base of the PR and between 57a855b and ee4435d.

📒 Files selected for processing (3)
  • .changeset/wild-moons-repeat.md
  • packages/form-core/src/FieldApi.ts
  • packages/form-core/tests/FieldApi.spec.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The async validation debounce callback now clears its stored timeout ID. A regression test verifies that isValidating becomes true during every async validation run and returns to false after completion.

Changes

Async validation state

Layer / File(s) Summary
Reset validation state and verify repeated runs
packages/form-core/src/FieldApi.ts, packages/form-core/tests/FieldApi.spec.ts, .changeset/wild-moons-repeat.md
The debounce callback clears its timeout ID before continuing. The test covers isValidating and validation errors across two async validation runs. A patch changeset documents the behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to ee443

Async field validation now reports isValidating during each repeated validation run, with regression coverage for error and recovery behavior. The change is ready to merge.

Suggested reviewers: pascalmh

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix: reporting isValidating on every async validation run.
Description check ✅ Passed The description includes the required Changes, Checklist, and Release Impact sections. It explains the cause, fix, tests, and release impact, with the applicable checklist items completed.
Linked Issues check ✅ Passed The implementation directly addresses issue [#2372] by clearing the fired debounce timer ID, preventing an incorrect pending-validation decrement. The regression test verifies isValidating during repe…
Out of Scope Changes check ✅ Passed The changeset, FieldApi fix, and regression test are all related to issue [#2372] and the stated PR objective. No unrelated code changes are identified.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async validator doesn't re-trigger isValidating after first failed validation

1 participant