Skip to content

Commit 7a1428d

Browse files
TkDodoautofix-ci[bot]harry-whorlow
authored
fix(core): field unmount (#2068)
* fix: make field unmount * more tests * ref: prefer object.entries * jsdoc * Fix field unmount issue in core Fixes the issue with field unmount in core. * ci: apply automated fixes and generate docs * fix: update fieldInfo type to be partial and handle unmounted fields * fix: cleanupFieldsOnUnmount flag * fix: eslint * fix: coderabbit findings * fix: ensure async validation cancellation on field unmount * more tests * chore: remove default * feat: listeners * ci: apply automated fixes and generate docs --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Harry Whorlow <79278353+harry-whorlow@users.noreply.github.com> Co-authored-by: Harry Whorlow <whorlowharry@gmail.com>
1 parent e21cc01 commit 7a1428d

8 files changed

Lines changed: 770 additions & 19 deletions

File tree

.changeset/red-hats-jam.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/form-core': patch
3+
'@tanstack/react-form': patch
4+
---
5+
6+
fix(core): field unmount

docs/framework/angular/guides/listeners.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ In this example, when the user changes the country, the selected province needs
1515

1616
Events that can be "listened" to are:
1717

18-
- onChange
19-
- onBlur
20-
- onMount
21-
- onSubmit
18+
- `onChange`
19+
- `onBlur`
20+
- `onMount`
21+
- `onSubmit`
22+
- `onUnmount`
2223

2324
```angular-ts
2425
@Component({

docs/framework/react/guides/listeners.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Events that can be "listened" to are:
1919
- `onBlur`
2020
- `onMount`
2121
- `onSubmit`
22+
- `onUnmount`
2223

2324
```tsx
2425
function App() {

docs/framework/vue/guides/listeners.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ In this example, when the user changes the country, the selected province needs
1515

1616
Events that can be "listened" to are:
1717

18-
- onChange
19-
- onBlur
20-
- onMount
21-
- onSubmit
18+
- `onChange`
19+
- `onBlur`
20+
- `onMount`
21+
- `onSubmit`
22+
- `onUnmount`
2223

2324
```vue
2425
<script setup>

packages/form-core/src/FieldApi.ts

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ export interface FieldListeners<
382382
onBlur?: FieldListenerFn<TParentData, TName, TData>
383383
onBlurDebounceMs?: number
384384
onMount?: FieldListenerFn<TParentData, TName, TData>
385+
onUnmount?: FieldListenerFn<TParentData, TName, TData>
385386
onSubmit?: FieldListenerFn<TParentData, TName, TData>
386387
}
387388

@@ -1275,6 +1276,7 @@ export class FieldApi<
12751276

12761277
/**
12771278
* Mounts the field instance to the form.
1279+
* @returns A function to unmount the field instance.
12781280
*/
12791281
mount = () => {
12801282
if (this.options.defaultValue !== undefined && !this.getMeta().isTouched) {
@@ -1322,8 +1324,86 @@ export class FieldApi<
13221324
fieldApi: this,
13231325
})
13241326

1325-
// TODO: Remove
1326-
return () => {}
1327+
return () => {
1328+
// Stop any in-flight async validation or listener work tied to this instance.
1329+
for (const [key, timeout] of Object.entries(
1330+
this.timeoutIds.validations,
1331+
)) {
1332+
if (timeout) {
1333+
clearTimeout(timeout)
1334+
this.timeoutIds.validations[
1335+
key as keyof typeof this.timeoutIds.validations
1336+
] = null
1337+
}
1338+
}
1339+
for (const [key, timeout] of Object.entries(this.timeoutIds.listeners)) {
1340+
if (timeout) {
1341+
clearTimeout(timeout)
1342+
this.timeoutIds.listeners[
1343+
key as keyof typeof this.timeoutIds.listeners
1344+
] = null
1345+
}
1346+
}
1347+
for (const [key, timeout] of Object.entries(
1348+
this.timeoutIds.formListeners,
1349+
)) {
1350+
if (timeout) {
1351+
clearTimeout(timeout)
1352+
this.timeoutIds.formListeners[
1353+
key as keyof typeof this.timeoutIds.formListeners
1354+
] = null
1355+
}
1356+
}
1357+
1358+
const fieldInfo = this.form.fieldInfo[this.name]
1359+
if (!fieldInfo) return
1360+
1361+
// If a newer field instance has already been mounted for this name,
1362+
// avoid touching its shared validation state during teardown.
1363+
if (fieldInfo.instance !== this) return
1364+
1365+
for (const [key, validationMeta] of Object.entries(
1366+
fieldInfo.validationMetaMap,
1367+
)) {
1368+
validationMeta?.lastAbortController.abort()
1369+
fieldInfo.validationMetaMap[
1370+
key as keyof typeof fieldInfo.validationMetaMap
1371+
] = undefined
1372+
}
1373+
1374+
this.form.baseStore.setState((prev) => ({
1375+
// Preserve interaction flags so field-level defaultValue does not
1376+
// reseed user-entered values on remount.
1377+
...prev,
1378+
fieldMetaBase: {
1379+
...prev.fieldMetaBase,
1380+
[this.name]: {
1381+
...defaultFieldMeta,
1382+
isTouched:
1383+
prev.fieldMetaBase[this.name]?.isTouched ??
1384+
defaultFieldMeta.isTouched,
1385+
isBlurred:
1386+
prev.fieldMetaBase[this.name]?.isBlurred ??
1387+
defaultFieldMeta.isBlurred,
1388+
isDirty:
1389+
prev.fieldMetaBase[this.name]?.isDirty ??
1390+
defaultFieldMeta.isDirty,
1391+
},
1392+
},
1393+
}))
1394+
1395+
fieldInfo.instance = null
1396+
1397+
this.options.listeners?.onUnmount?.({
1398+
value: this.state.value,
1399+
fieldApi: this,
1400+
})
1401+
1402+
this.form.options.listeners?.onFieldUnmount?.({
1403+
formApi: this.form,
1404+
fieldApi: this,
1405+
})
1406+
}
13271407
}
13281408

13291409
/**
@@ -1790,12 +1870,13 @@ export class FieldApi<
17901870
promises: Promise<ValidationError | undefined>[],
17911871
) => {
17921872
const errorMapKey = getErrorMapKey(validateObj.cause)
1793-
const fieldValidatorMeta = field.getInfo().validationMetaMap[errorMapKey]
1873+
const fieldInfo = field.getInfo()
1874+
const fieldValidatorMeta = fieldInfo.validationMetaMap[errorMapKey]
17941875

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

1798-
this.getInfo().validationMetaMap[errorMapKey] = {
1879+
fieldInfo.validationMetaMap[errorMapKey] = {
17991880
lastAbortController: controller,
18001881
}
18011882

@@ -1804,11 +1885,11 @@ export class FieldApi<
18041885
let rawError!: ValidationError | undefined
18051886
try {
18061887
rawError = await new Promise((rawResolve, rawReject) => {
1807-
if (this.timeoutIds.validations[validateObj.cause]) {
1808-
clearTimeout(this.timeoutIds.validations[validateObj.cause]!)
1888+
if (field.timeoutIds.validations[validateObj.cause]) {
1889+
clearTimeout(field.timeoutIds.validations[validateObj.cause]!)
18091890
}
18101891

1811-
this.timeoutIds.validations[validateObj.cause] = setTimeout(
1892+
field.timeoutIds.validations[validateObj.cause] = setTimeout(
18121893
async () => {
18131894
if (controller.signal.aborted) return rawResolve(undefined)
18141895
try {
@@ -1838,14 +1919,20 @@ export class FieldApi<
18381919

18391920
const fieldLevelError = normalizeError(rawError)
18401921
const formLevelError =
1841-
asyncFormValidationResults[this.name]?.[errorMapKey]
1922+
asyncFormValidationResults[
1923+
field.name as keyof typeof asyncFormValidationResults
1924+
]?.[errorMapKey]
18421925

18431926
const { newErrorValue, newSource } =
18441927
determineFieldLevelErrorSourceAndValue({
18451928
formLevelError,
18461929
fieldLevelError,
18471930
})
18481931

1932+
if (field.getInfo().instance !== field) {
1933+
return resolve(undefined)
1934+
}
1935+
18491936
field.setMeta((prev) => {
18501937
return {
18511938
...prev,

packages/form-core/src/FormApi.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,24 @@ export interface FormListeners<
306306
>
307307
meta: TSubmitMeta
308308
}) => void
309+
310+
onFieldUnmount?: (props: {
311+
formApi: FormApi<
312+
TFormData,
313+
TOnMount,
314+
TOnChange,
315+
TOnChangeAsync,
316+
TOnBlur,
317+
TOnBlurAsync,
318+
TOnSubmit,
319+
TOnSubmitAsync,
320+
TOnDynamic,
321+
TOnDynamicAsync,
322+
TOnServer,
323+
TSubmitMeta
324+
>
325+
fieldApi: AnyFieldApi
326+
}) => void
309327
}
310328

311329
/**
@@ -925,7 +943,7 @@ export class FormApi<
925943
/**
926944
* A record of field information for each field in the form.
927945
*/
928-
fieldInfo: Record<DeepKeys<TFormData>, FieldInfo<TFormData>> = {} as any
946+
fieldInfo: Partial<Record<DeepKeys<TFormData>, FieldInfo<TFormData>>> = {}
929947

930948
get state() {
931949
return this.store.state
@@ -1603,7 +1621,6 @@ export class FormApi<
16031621
field: TField,
16041622
cause: ValidationCause,
16051623
) => {
1606-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
16071624
const fieldInstance = this.fieldInfo[field]?.instance
16081625

16091626
if (!fieldInstance) {
@@ -2222,7 +2239,6 @@ export class FormApi<
22222239
getFieldInfo = <TField extends DeepKeys<TFormData>>(
22232240
field: TField,
22242241
): FieldInfo<TFormData> => {
2225-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
22262242
return (this.fieldInfo[field] ||= {
22272243
instance: null,
22282244
validationMetaMap: {

0 commit comments

Comments
 (0)