Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
}
}

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
}

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