Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/red-hats-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/form-core': patch
'@tanstack/react-form': patch
---

fix(core): field unmount
77 changes: 75 additions & 2 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,7 @@ export class FieldApi<

/**
* Mounts the field instance to the form.
* @returns A function to unmount the field instance.
*/
mount = () => {
if (this.options.defaultValue !== undefined && !this.getMeta().isTouched) {
Expand Down Expand Up @@ -1322,8 +1323,80 @@ export class FieldApi<
fieldApi: this,
})

// TODO: Remove
return () => {}
if (!this.form.options.cleanupFieldsOnUnmount) {
return () => {}
}

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
}
}
Comment on lines +1330 to +1359
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.


const fieldInfo = this.form.fieldInfo[this.name]
if (!fieldInfo) return

// 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
}
Comment on lines +1364 to +1375
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.


this.form.baseStore.setState((prev) => ({
// Preserve interaction flags so field-level defaultValue does not
// reseed user-entered values on remount.
...prev,
fieldMetaBase: {
...prev.fieldMetaBase,
[this.name]: {
...defaultFieldMeta,
isTouched:
prev.fieldMetaBase[this.name]?.isTouched ??
defaultFieldMeta.isTouched,
isBlurred:
prev.fieldMetaBase[this.name]?.isBlurred ??
defaultFieldMeta.isBlurred,
isDirty:
prev.fieldMetaBase[this.name]?.isDirty ??
defaultFieldMeta.isDirty,
},
},
}))

fieldInfo.instance = null
}
}

/**
Expand Down
9 changes: 6 additions & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,11 @@ export interface FormOptions<
* If true, allows the form to be submitted in an invalid state i.e. canSubmit will remain true regardless of validation errors. Defaults to undefined.
*/
canSubmitWhenInvalid?: boolean
/**
* If true, mounted fields clean up their validation state when they unmount.
* Defaults to false.
*/
cleanupFieldsOnUnmount?: boolean
/**
* A list of validators to pass to the form
*/
Expand Down Expand Up @@ -925,7 +930,7 @@ export class FormApi<
/**
* A record of field information for each field in the form.
*/
fieldInfo: Record<DeepKeys<TFormData>, FieldInfo<TFormData>> = {} as any
fieldInfo: Partial<Record<DeepKeys<TFormData>, FieldInfo<TFormData>>> = {}

get state() {
return this.store.state
Expand Down Expand Up @@ -1603,7 +1608,6 @@ export class FormApi<
field: TField,
cause: ValidationCause,
) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const fieldInstance = this.fieldInfo[field]?.instance

if (!fieldInstance) {
Expand Down Expand Up @@ -2222,7 +2226,6 @@ export class FormApi<
getFieldInfo = <TField extends DeepKeys<TFormData>>(
field: TField,
): FieldInfo<TFormData> => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return (this.fieldInfo[field] ||= {
instance: null,
validationMetaMap: {
Expand Down
Loading
Loading