Skip to content

fix(core): field unmount#2068

Open
TkDodo wants to merge 10 commits intoTanStack:mainfrom
TkDodo:feature/fix/field-unmount
Open

fix(core): field unmount#2068
TkDodo wants to merge 10 commits intoTanStack:mainfrom
TkDodo:feature/fix/field-unmount

Conversation

@TkDodo
Copy link

@TkDodo TkDodo commented Mar 6, 2026

Add unmounting to fields. field.mount now returns a cleanup function for unmounting.

Without unmounting, conditionally rendered fields will keep their field-level validations running even though the field isn’t rendered anymore.

Summary by CodeRabbit

  • New Features

    • Added a form option to control field cleanup on unmount: cleanupFieldsOnUnmount (defaults to false).
    • Field mount now provides an explicit cleanup/unmount function.
  • Bug Fixes

    • Unmount now aborts in-flight validations and cancels delayed listeners to prevent stale callbacks or state.
  • Tests

    • Expanded tests for unmount cleanup, value/meta preservation, async validation cancellation, and remount behavior.

@changeset-bot
Copy link

changeset-bot bot commented Mar 6, 2026

🦋 Changeset detected

Latest commit: 8b41487

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@tanstack/form-core Patch
@tanstack/react-form Patch
@tanstack/angular-form Patch
@tanstack/form-devtools Patch
@tanstack/lit-form Patch
@tanstack/solid-form Patch
@tanstack/svelte-form Patch
@tanstack/vue-form Patch
@tanstack/react-form-nextjs Patch
@tanstack/react-form-remix Patch
@tanstack/react-form-start Patch
@tanstack/react-form-devtools Patch
@tanstack/solid-form-devtools Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Fixes the issue with field unmount in core.
@nx-cloud
Copy link

nx-cloud bot commented Mar 6, 2026

View your CI Pipeline Execution ↗ for commit 8b41487

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 1m 42s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 30s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-13 19:25:16 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 6, 2026

More templates

@tanstack/angular-form

npm i https://pkg.pr.new/@tanstack/angular-form@2068

@tanstack/form-core

npm i https://pkg.pr.new/@tanstack/form-core@2068

@tanstack/form-devtools

npm i https://pkg.pr.new/@tanstack/form-devtools@2068

@tanstack/lit-form

npm i https://pkg.pr.new/@tanstack/lit-form@2068

@tanstack/react-form

npm i https://pkg.pr.new/@tanstack/react-form@2068

@tanstack/react-form-devtools

npm i https://pkg.pr.new/@tanstack/react-form-devtools@2068

@tanstack/react-form-nextjs

npm i https://pkg.pr.new/@tanstack/react-form-nextjs@2068

@tanstack/react-form-remix

npm i https://pkg.pr.new/@tanstack/react-form-remix@2068

@tanstack/react-form-start

npm i https://pkg.pr.new/@tanstack/react-form-start@2068

@tanstack/solid-form

npm i https://pkg.pr.new/@tanstack/solid-form@2068

@tanstack/solid-form-devtools

npm i https://pkg.pr.new/@tanstack/solid-form-devtools@2068

@tanstack/svelte-form

npm i https://pkg.pr.new/@tanstack/svelte-form@2068

@tanstack/vue-form

npm i https://pkg.pr.new/@tanstack/vue-form@2068

commit: e9f4cbb

@TkDodo TkDodo marked this pull request as ready for review March 6, 2026 12:48
@sentry
Copy link

sentry bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 90.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.13%. Comparing base (6892ed0) to head (8b41487).
⚠️ Report is 154 commits behind head on main.

Files with missing lines Patch % Lines
packages/form-core/src/FieldApi.ts 89.65% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2068      +/-   ##
==========================================
- Coverage   90.35%   90.13%   -0.22%     
==========================================
  Files          38       49      +11     
  Lines        1752     2038     +286     
  Branches      444      532      +88     
==========================================
+ Hits         1583     1837     +254     
- Misses        149      181      +32     
  Partials       20       20              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@TkDodo TkDodo requested a review from crutchcorn March 7, 2026 08:36
@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

Adds an optional Form option cleanupFieldsOnUnmount; FieldApi.mount now returns an explicit unmount/teardown function that may abort validations, cancel timers/listeners, and reset per-field metadata; tests and a changeset were added.

Changes

Cohort / File(s) Summary
Changeset
​.changeset/red-hats-jam.md
New changeset noting patch bumps for @tanstack/form-core and @tanstack/react-form and recording the core field unmount fix.
Form options & types
packages/form-core/src/FormApi.ts
Added public cleanupFieldsOnUnmount?: boolean (default false). Changed fieldInfo to Partial<Record<...>> and removed two ESLint suppression comments to accommodate optional entries.
Field lifecycle / mount API
packages/form-core/src/FieldApi.ts
FieldApi.mount documents and now returns an unmount/teardown function. When enabled, teardown aborts in-flight validations, clears timeouts/listeners, resets per-field validation/meta (preserving interaction flags where appropriate), nulls fieldInfo.instance, and guards against tearing down newer instances.
Core tests
packages/form-core/tests/FieldApi.spec.ts
Added extensive tests for unmount behavior: meta preservation vs clearing, async validation cancellation, debounced listener cancellation, newer-instance protection, value persistence across remounts, array/nested scenarios, and various validator triggers.
React integration test
packages/react-form/tests/useField.test.tsx
Added test verifying hidden field submit errors are cleared on unmount with cleanupFieldsOnUnmount: true, while values persist and submit validators run correctly after remount.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant Form as FormApi
    participant Field as FieldApi
    participant Val as ValidationSystem

    App->>Form: create(formOptions with cleanupFieldsOnUnmount=true)
    App->>Field: mount field
    Field->>Form: register field instance & metadata
    Field->>Val: start validations / set timeouts / listeners
    Field-->>App: return cleanup function

    Note over Val: validations/timeouts in-flight

    App->>Field: call cleanup() on unmount
    Field->>Val: abort controllers & cancel timeouts/listeners
    Val-->>Val: stop async callbacks
    Field->>Form: reset field meta, clear instance reference
    Field-->>App: cleanup complete
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibbled code where fields might dwell,
I cancel flights and clear each trail,
I keep the value, tidy the map,
Protect the new while old ones nap,
Hoppity hop — the form is hale.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is incomplete. It lacks the required 'Checklist' section (Contributing guide steps and testing confirmation) and 'Release Impact' section (changeset confirmation) from the template. Complete the description by adding the required checklist items and release impact confirmation, including confirmation that the changeset has been generated.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding unmount functionality to fields, which is the primary objective of this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/form-core/src/FieldApi.ts`:
- Around line 1361-1375: The abort loop currently runs before checking whether
this FieldApi instance is still the active one, which can cancel validations for
a newer remounted instance; move the instance check so it runs before touching
shared state (i.e., check fieldInfo.instance !== this and return early), or
alternatively inside the loop verify fieldInfo.instance === this before calling
validationMeta?.lastAbortController.abort() and clearing entries; update the
code paths around fieldInfo, validationMetaMap, lastAbortController, and the
instance guard to ensure only the active FieldApi instance aborts and clears the
shared validationMetaMap.
- Around line 1377-1383: The current reset in FieldApi using
this.form.baseStore.setState(...) replaces prev.fieldMetaBase[this.name] with
defaultFieldMeta and thus wipes preserved state on unmount/remount; update the
setter to merge with any existing meta for this.name instead of overwriting so
preserved flags/values survive (e.g., read existing =
prev.fieldMetaBase?.[this.name], then set fieldMetaBase[this.name] = {
...defaultFieldMeta, ...existing } or selectively preserve keys like
touched/value/defaultValueSeeded) so the logic in FieldApi that reseeds
options.defaultValue (referenced by the FieldApi methods around lines that
handle defaultValue reseeding) no longer loses user-entered values on remount.

In `@packages/react-form/tests/useField.test.tsx`:
- Around line 422-423: The test asserts onSubmit immediately after clicking
submit but form.handleSubmit() executes asynchronously; update the test to wait
for the async submit path to complete before asserting by awaiting a wait helper
that checks onSubmit (e.g., use waitFor or another async wait) so the assertion
verifies that onSubmit was called after form._handleSubmit()/form.handleSubmit()
finishes; reference the submitButton click and the onSubmit mock in the updated
assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2c69fac-1e7b-4d00-b470-721c4ba1c718

📥 Commits

Reviewing files that changed from the base of the PR and between a4e8dec and 4eff6fa.

📒 Files selected for processing (5)
  • .changeset/red-hats-jam.md
  • packages/form-core/src/FieldApi.ts
  • packages/form-core/src/FormApi.ts
  • packages/form-core/tests/FieldApi.spec.ts
  • packages/react-form/tests/useField.test.tsx

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/form-core/src/FieldApi.ts`:
- Around line 1364-1375: The teardown skips aborting async work started by the
older FieldApi instance because it only avoids touching shared controllers when
a newer instance is mounted; fix by either tracking abort controllers per
FieldApi instance or by ignoring async completions when the instance no longer
matches. Concretely: change the validation logic so each FieldApi stores its own
controllers (e.g., a per-instance map on this) instead of reusing
fieldInfo.validationMetaMap.lastAbortController, and abort only those on
teardown; or add a guard in the async validator completion path (before calling
field.setMeta(...) / inside FieldApi's async result handling) that checks
field.getInfo().instance === this and returns early if it differs. Ensure you
reference validationMetaMap, lastAbortController, FieldApi, setMeta and getInfo
when locating the relevant code to update.
- Around line 1330-1359: Currently async validation/listener
timeouts/controllers are attached to the active instance (this) even when the
async work targets a different field, so the target field cannot cancel them on
unmount; update the code that schedules linked-field async work (the
validateAsync caller that writes to getInfo().validationMetaMap and
this.timeoutIds.validations / this.timeoutIds.listeners) to store ownership on
the target field instead of this (i.e., use the target field's timeoutIds and
validationMetaMap entry keys), and update the teardown returned by the FieldApi
cleanup to clear any timeouts/controllers stored on the field itself (iterate
and clear entries on field.timeoutIds.validations/listeners/formListeners and
remove related validationMetaMap entries) so the target field’s unmount cancels
its own async work. Ensure you reference and update validateAsync,
getInfo().validationMetaMap, and timeoutIds.validations/listeners/formListeners
usage sites so ownership is moved to the field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 39798198-5e48-4350-8160-450fd6a0e1ea

📥 Commits

Reviewing files that changed from the base of the PR and between 10abde0 and 8b41487.

📒 Files selected for processing (3)
  • packages/form-core/src/FieldApi.ts
  • packages/form-core/tests/FieldApi.spec.ts
  • packages/react-form/tests/useField.test.tsx

Comment on lines +1330 to +1359
return () => {
// Stop any in-flight async validation or listener work tied to this instance.
for (const [key, timeout] of Object.entries(
this.timeoutIds.validations,
)) {
if (timeout) {
clearTimeout(timeout)
this.timeoutIds.validations[
key as keyof typeof this.timeoutIds.validations
] = null
}
}
for (const [key, timeout] of Object.entries(this.timeoutIds.listeners)) {
if (timeout) {
clearTimeout(timeout)
this.timeoutIds.listeners[
key as keyof typeof this.timeoutIds.listeners
] = null
}
}
for (const [key, timeout] of Object.entries(
this.timeoutIds.formListeners,
)) {
if (timeout) {
clearTimeout(timeout)
this.timeoutIds.formListeners[
key as keyof typeof this.timeoutIds.formListeners
] = null
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Linked-field async work is not owned by the field being unmounted.

These loops only clear handles stored on this, but validateAsync stores linked-field work on the source field via this.getInfo().validationMetaMap[...] on Line 1871 and this.timeoutIds.validations[...] on Lines 1880-1884 even when field !== this. If a hidden field uses onChangeListenTo/onBlurListenTo with async validators, its teardown cannot cancel already-enqueued work and stale errors can still land after unmount. Please move timeout/controller ownership to field so the target field's cleanup can actually stop it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/form-core/src/FieldApi.ts` around lines 1330 - 1359, Currently async
validation/listener timeouts/controllers are attached to the active instance
(this) even when the async work targets a different field, so the target field
cannot cancel them on unmount; update the code that schedules linked-field async
work (the validateAsync caller that writes to getInfo().validationMetaMap and
this.timeoutIds.validations / this.timeoutIds.listeners) to store ownership on
the target field instead of this (i.e., use the target field's timeoutIds and
validationMetaMap entry keys), and update the teardown returned by the FieldApi
cleanup to clear any timeouts/controllers stored on the field itself (iterate
and clear entries on field.timeoutIds.validations/listeners/formListeners and
remove related validationMetaMap entries) so the target field’s unmount cancels
its own async work. Ensure you reference and update validateAsync,
getInfo().validationMetaMap, and timeoutIds.validations/listeners/formListeners
usage sites so ownership is moved to the field.

Comment on lines +1364 to +1375
// If a newer field instance has already been mounted for this name,
// avoid touching its shared validation state during teardown.
if (fieldInfo.instance !== this) return

for (const [key, validationMeta] of Object.entries(
fieldInfo.validationMetaMap,
)) {
validationMeta?.lastAbortController.abort()
fieldInfo.validationMetaMap[
key as keyof typeof fieldInfo.validationMetaMap
] = undefined
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Older-instance async validators can still write stale meta after a remount.

The guard on Line 1366 prevents aborting the newer instance's shared controller, but it also skips aborting work started by the older instance. Those promises still commit through field.setMeta(...) on Line 1922, so an old field can overwrite the remounted field's shared meta once it resolves. Please either track abort controllers per FieldApi instance or ignore async completions when field.getInfo().instance !== field.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/form-core/src/FieldApi.ts` around lines 1364 - 1375, The teardown
skips aborting async work started by the older FieldApi instance because it only
avoids touching shared controllers when a newer instance is mounted; fix by
either tracking abort controllers per FieldApi instance or by ignoring async
completions when the instance no longer matches. Concretely: change the
validation logic so each FieldApi stores its own controllers (e.g., a
per-instance map on this) instead of reusing
fieldInfo.validationMetaMap.lastAbortController, and abort only those on
teardown; or add a guard in the async validator completion path (before calling
field.setMeta(...) / inside FieldApi's async result handling) that checks
field.getInfo().instance === this and returns early if it differs. Ensure you
reference validationMetaMap, lastAbortController, FieldApi, setMeta and getInfo
when locating the relevant code to update.

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.

1 participant