Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
64 changes: 62 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,67 @@ 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

for (const [key, validationMeta] of Object.entries(
fieldInfo.validationMetaMap,
)) {
validationMeta?.lastAbortController.abort()
fieldInfo.validationMetaMap[
key as keyof typeof fieldInfo.validationMetaMap
] = undefined
}

// If a newer field instance has already been mounted for this name,
// avoid clearing its state during teardown of an older instance.
if (fieldInfo.instance !== this) return

this.form.baseStore.setState((prev) => ({
...prev,
fieldMetaBase: {
...prev.fieldMetaBase,
[this.name]: defaultFieldMeta,
},
}))

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
209 changes: 209 additions & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,215 @@ describe('field api', () => {
expect(form.getFieldInfo(field.name)).toBeDefined()
})

it('should keep field meta on unmount by default', async () => {
const form = new FormApi({
defaultValues: {
name: '',
},
})

form.mount()

const field = new FieldApi({
form,
name: 'name',
validators: {
onSubmit: ({ value }) =>
value.length > 0 ? undefined : 'name is required',
},
})

const unmount = field.mount()

await form.handleSubmit()
expect(form.state.fieldMeta.name?.errors).toContain('name is required')

expect(unmount).toBeTypeOf('function')
unmount()
expect(form.state.fieldMeta.name?.errors).toContain('name is required')
expect(form.state.canSubmit).toBe(false)
})

it('should clear meta on unmount while preserving value', async () => {
const form = new FormApi({
defaultValues: {
firstName: 'a',
lastName: 'abc',
},
cleanupFieldsOnUnmount: true,
onSubmit: () => {},
})

form.mount()

const firstName = new FieldApi({
form,
name: 'firstName',
})
const lastName = new FieldApi({
form,
name: 'lastName',
validators: {
onSubmit: ({ value }) =>
value.length >= 5 ? undefined : 'last name must be at least 5 chars',
},
})

firstName.mount()
const unmountLastName = lastName.mount()

await form.handleSubmit()
expect(form.state.canSubmit).toBe(false)
expect(lastName.getMeta().errors).toContain(
'last name must be at least 5 chars',
)

expect(unmountLastName).toBeTypeOf('function')
unmountLastName?.()

expect(form.getFieldValue('lastName')).toBe('abc')
expect(form.state.fieldMeta.lastName).toMatchObject({
isTouched: false,
isValid: true,
errors: [],
})
expect(form.state.canSubmit).toBe(true)

const remountedLastName = new FieldApi({
form,
name: 'lastName',
validators: {
onSubmit: ({ value }) =>
value.length >= 5 ? undefined : 'last name must be at least 5 chars',
},
})

remountedLastName.mount()
expect(remountedLastName.getMeta().errors).toStrictEqual([])
expect(remountedLastName.getMeta().isTouched).toBe(false)
expect(remountedLastName.getValue()).toBe('abc')
})

it('should not apply in-flight async validation results after unmount', async () => {
vi.useFakeTimers()

let resolveValidation!: () => void
const validationPromise = new Promise<void>((resolve) => {
resolveValidation = resolve
})

const form = new FormApi({
defaultValues: {
name: '',
},
cleanupFieldsOnUnmount: true,
})

form.mount()

const field = new FieldApi({
form,
name: 'name',
validators: {
onChangeAsyncDebounceMs: 0,
onChangeAsync: async () => {
await validationPromise
return 'async error should be ignored after unmount'
},
},
})

const unmount = field.mount()

field.setValue('trigger')
await vi.runAllTimersAsync()

expect(unmount).toBeTypeOf('function')
unmount?.()
resolveValidation()
await vi.runAllTimersAsync()

expect(form.state.fieldMeta.name).toMatchObject({
isTouched: false,
isValid: true,
errors: [],
})

vi.useRealTimers()
})

it('should cancel debounced field and form listeners on unmount', async () => {
vi.useFakeTimers()

const fieldListener = vi.fn()
const formListener = vi.fn()

const form = new FormApi({
defaultValues: {
name: '',
},
cleanupFieldsOnUnmount: true,
listeners: {
onChange: formListener,
onChangeDebounceMs: 200,
},
})

form.mount()

const field = new FieldApi({
form,
name: 'name',
listeners: {
onChange: fieldListener,
onChangeDebounceMs: 200,
},
})

const unmount = field.mount()
field.setValue('trigger')
expect(unmount).toBeTypeOf('function')
unmount?.()

await vi.advanceTimersByTimeAsync(500)

expect(fieldListener).toHaveBeenCalledTimes(0)
expect(formListener).toHaveBeenCalledTimes(0)

vi.useRealTimers()
})

it('should not clear newer instance state when older instance unmounts', () => {
const form = new FormApi({
defaultValues: {
name: '',
},
cleanupFieldsOnUnmount: true,
})

form.mount()

const oldField = new FieldApi({
form,
name: 'name',
})
const oldUnmount = oldField.mount()

const newField = new FieldApi({
form,
name: 'name',
})
newField.mount()
newField.setValue('new value')

expect(oldUnmount).toBeTypeOf('function')
oldUnmount?.()

expect(form.getFieldInfo('name').instance).toBe(newField)
expect(newField.getValue()).toBe('new value')
expect(newField.getMeta().isTouched).toBe(true)
})

it('should show onSubmit errors', async () => {
const form = new FormApi({
defaultValues: {
Expand Down
Loading
Loading