Skip to content
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
96 changes: 88 additions & 8 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 Expand Up @@ -1790,12 +1863,13 @@ export class FieldApi<
promises: Promise<ValidationError | undefined>[],
) => {
const errorMapKey = getErrorMapKey(validateObj.cause)
const fieldValidatorMeta = field.getInfo().validationMetaMap[errorMapKey]
const fieldInfo = field.getInfo()
const fieldValidatorMeta = fieldInfo.validationMetaMap[errorMapKey]

fieldValidatorMeta?.lastAbortController.abort()
const controller = new AbortController()

this.getInfo().validationMetaMap[errorMapKey] = {
fieldInfo.validationMetaMap[errorMapKey] = {
lastAbortController: controller,
}

Expand All @@ -1804,11 +1878,11 @@ export class FieldApi<
let rawError!: ValidationError | undefined
try {
rawError = await new Promise((rawResolve, rawReject) => {
if (this.timeoutIds.validations[validateObj.cause]) {
clearTimeout(this.timeoutIds.validations[validateObj.cause]!)
if (field.timeoutIds.validations[validateObj.cause]) {
clearTimeout(field.timeoutIds.validations[validateObj.cause]!)
}

this.timeoutIds.validations[validateObj.cause] = setTimeout(
field.timeoutIds.validations[validateObj.cause] = setTimeout(
async () => {
if (controller.signal.aborted) return rawResolve(undefined)
try {
Expand Down Expand Up @@ -1838,14 +1912,20 @@ export class FieldApi<

const fieldLevelError = normalizeError(rawError)
const formLevelError =
asyncFormValidationResults[this.name]?.[errorMapKey]
asyncFormValidationResults[
field.name as keyof typeof asyncFormValidationResults
]?.[errorMapKey]

const { newErrorValue, newSource } =
determineFieldLevelErrorSourceAndValue({
formLevelError,
fieldLevelError,
})

if (field.getInfo().instance !== field) {
return resolve(undefined)
}

field.setMeta((prev) => {
return {
...prev,
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