Skip to content
Merged
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
9 changes: 5 additions & 4 deletions docs/framework/angular/guides/listeners.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ In this example, when the user changes the country, the selected province needs

Events that can be "listened" to are:

- onChange
- onBlur
- onMount
- onSubmit
- `onChange`
- `onBlur`
- `onMount`
- `onSubmit`
- `onUnmount`

```angular-ts
@Component({
Expand Down
1 change: 1 addition & 0 deletions docs/framework/react/guides/listeners.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Events that can be "listened" to are:
- `onBlur`
- `onMount`
- `onSubmit`
- `onUnmount`

```tsx
function App() {
Expand Down
9 changes: 5 additions & 4 deletions docs/framework/vue/guides/listeners.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ In this example, when the user changes the country, the selected province needs

Events that can be "listened" to are:

- onChange
- onBlur
- onMount
- onSubmit
- `onChange`
- `onBlur`
- `onMount`
- `onSubmit`
- `onUnmount`

```vue
<script setup>
Expand Down
103 changes: 95 additions & 8 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ export interface FieldListeners<
onBlur?: FieldListenerFn<TParentData, TName, TData>
onBlurDebounceMs?: number
onMount?: FieldListenerFn<TParentData, TName, TData>
onUnmount?: FieldListenerFn<TParentData, TName, TData>
onSubmit?: FieldListenerFn<TParentData, TName, TData>
}

Expand Down Expand Up @@ -1275,6 +1276,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 +1324,86 @@ export class FieldApi<
fieldApi: this,
})

// TODO: Remove
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

this.options.listeners?.onUnmount?.({
value: this.state.value,
fieldApi: this,
})

this.form.options.listeners?.onFieldUnmount?.({
formApi: this.form,
fieldApi: this,
})
}
}

/**
Expand Down Expand Up @@ -1790,12 +1870,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 +1885,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 +1919,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
22 changes: 19 additions & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,24 @@ export interface FormListeners<
>
meta: TSubmitMeta
}) => void

onFieldUnmount?: (props: {
formApi: FormApi<
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TOnServer,
TSubmitMeta
>
fieldApi: AnyFieldApi
}) => void
}

/**
Expand Down Expand Up @@ -925,7 +943,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 +1621,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 +2239,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