Skip to content
Open
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
60 changes: 58 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,63 @@ 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

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
4 changes: 1 addition & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,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 +1603,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 +2221,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
172 changes: 172 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,178 @@ describe('field api', () => {
expect(form.getFieldInfo(field.name)).toBeDefined()
})

it('should clear meta on unmount while preserving value', async () => {
const form = new FormApi({
defaultValues: {
firstName: 'a',
lastName: 'abc',
},
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',
)

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: '',
},
})

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()

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: '',
},
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')
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: '',
},
})

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')

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
101 changes: 101 additions & 0 deletions packages/react-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,107 @@ describe('useField', () => {
expect((getByTestId('first-field') as HTMLInputElement).value).toBe('hello')
})

it('should not keep hidden field submit errors after unmount', async () => {
const onSubmit = vi.fn()

function Comp() {
const form = useForm({
defaultValues: {
firstName: '',
lastName: '',
},
onSubmit: ({ value }) => onSubmit(value),
})

return (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.Field name="firstName">
{(field) => (
<input
data-testid="first-name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>

<form.Subscribe selector={(state) => state.values.firstName === 'a'}>
{(showLastName) =>
showLastName ? (
<form.Field
name="lastName"
validators={{
onSubmit: ({ value }) =>
value.length >= 5 ? undefined : 'lastName too short',
}}
>
{(field) => (
<input
data-testid="last-name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>
) : null
}
</form.Subscribe>

<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button data-testid="submit" type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
)
}

const { getByTestId, queryByTestId } = render(
<StrictMode>
<Comp />
</StrictMode>,
)

const submitButton = getByTestId('submit')

await user.type(getByTestId('first-name'), 'a')
await user.type(getByTestId('last-name'), 'abc')
await user.click(submitButton)

await waitFor(() => expect(submitButton).toBeDisabled())
expect(onSubmit).toHaveBeenCalledTimes(0)

await user.clear(getByTestId('first-name'))
await user.type(getByTestId('first-name'), 'b')

await waitFor(() =>
expect(queryByTestId('last-name')).not.toBeInTheDocument(),
)
await waitFor(() => expect(submitButton).toBeEnabled())

await user.click(submitButton)
expect(onSubmit).toHaveBeenCalledTimes(1)

await user.clear(getByTestId('first-name'))
await user.type(getByTestId('first-name'), 'a')

const remountedLastName = await waitFor(() => getByTestId('last-name'))
expect((remountedLastName as HTMLInputElement).value).toBe('abc')
expect(submitButton).toBeEnabled()
})

it('should validate async on change', async () => {
type Person = {
firstName: string
Expand Down
Loading