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
5 changes: 5 additions & 0 deletions .changeset/tidy-validation-counters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

Preserve field validation state across form resets by discarding stale work waiting for form validation and preventing older completions or debounce cancellations from decrementing current validation counters.
74 changes: 49 additions & 25 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,13 @@ export class FieldApi<
formListeners: Record<ListenerCause, ReturnType<typeof setTimeout> | null>
}

private validationTimeouts: Partial<
Record<
ValidationCause,
{ id: ReturnType<typeof setTimeout>; generation: number }
>
> = {}

/**
* Initializes a new `FieldApi` instance.
*/
Expand Down Expand Up @@ -1425,6 +1432,7 @@ export class FieldApi<
>
>,
) => {
const validationGeneration = this.form._validationGeneration
const validates = getAsyncValidatorArray(cause, {
...this.options,
form: this.form,
Expand All @@ -1435,6 +1443,7 @@ export class FieldApi<

// Get the field-specific error messages that are coming from the form's validator
const asyncFormValidationResults = await formValidationResultPromise
if (validationGeneration !== this.form._validationGeneration) return []

const linkedFields = this.getLinkedFields(cause)
const linkedFieldValidates = linkedFields.reduce(
Expand Down Expand Up @@ -1506,33 +1515,45 @@ export class FieldApi<
let rawError!: ValidationError | undefined
try {
rawError = await new Promise((rawResolve, rawReject) => {
if (field.timeoutIds.validations[validateObj.cause]) {
clearTimeout(field.timeoutIds.validations[validateObj.cause]!)
field.endValidation()
const previousTimeout =
field.timeoutIds.validations[validateObj.cause]
if (previousTimeout) {
clearTimeout(previousTimeout)
const previousValidation =
field.validationTimeouts[validateObj.cause]
// Only a timer owned by the pre-reset run loses its decrement.
if (
previousValidation?.id !== previousTimeout ||
previousValidation.generation === validationGeneration
) {
field.endValidation()
}
}

field.timeoutIds.validations[validateObj.cause] = setTimeout(
async () => {
if (controller.signal.aborted) return rawResolve(undefined)
try {
rawResolve(
await this.runValidator({
validate: validateObj.validate,
value: {
value: field.store.state.value,
fieldApi: field,
signal: controller.signal,
validationSource: 'field',
},
type: 'validateAsync',
}),
)
} catch (e) {
rawReject(e)
}
},
validateObj.debounceMs,
)
const timeoutId = setTimeout(async () => {
if (controller.signal.aborted) return rawResolve(undefined)
try {
rawResolve(
await this.runValidator({
validate: validateObj.validate,
value: {
value: field.store.state.value,
fieldApi: field,
signal: controller.signal,
validationSource: 'field',
},
type: 'validateAsync',
}),
)
} catch (e) {
rawReject(e)
}
}, validateObj.debounceMs)
field.timeoutIds.validations[validateObj.cause] = timeoutId
field.validationTimeouts[validateObj.cause] = {
id: timeoutId,
generation: validationGeneration,
}
})
} catch (e: unknown) {
rawError = e as ValidationError
Expand Down Expand Up @@ -1597,6 +1618,9 @@ export class FieldApi<

// Only reset isValidating if we set it to true earlier
batch(() => {
// Reset replaces the counters; an older run no longer owns a decrement.
if (validationGeneration !== this.form._validationGeneration) return

if (hasAsyncValidators) {
this.endValidation()
}
Expand Down
5 changes: 5 additions & 0 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,10 @@ export class FormApi<
* @private
*/
_formId: string
/**
* @private
*/
_validationGeneration = 0
/**
* @private
*/
Expand Down Expand Up @@ -1808,6 +1812,7 @@ export class FormApi<
* @param opts - Optional options to control the reset behavior.
*/
reset = (values?: TFormData, opts?: { keepDefaultValues?: boolean }) => {
this._validationGeneration++
const { fieldMeta: currentFieldMeta } = this.state
const fieldMetaBase = this.resetFieldMeta(currentFieldMeta)

Expand Down
227 changes: 227 additions & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2254,6 +2254,233 @@ describe('field api', () => {
expect(fn).toHaveBeenCalledTimes(1)
})

it.each([
{ linked: false, reset: false },
{ linked: false, reset: true },
{ linked: true, reset: false },
{ linked: true, reset: true },
])(
'should preserve pending validation when an older run settles (linked: $linked, reset: $reset)',
async ({ linked, reset }) => {
vi.useFakeTimers()
try {
const form = new FormApi({
defaultValues: { source: '', email: '' },
})
form.mount()

let resolveChange!: (error: string | undefined) => void
const changeResult = new Promise<string | undefined>((resolve) => {
resolveChange = resolve
})
let resolveBlur!: (error: string | undefined) => void
const blurResult = new Promise<string | undefined>((resolve) => {
resolveBlur = resolve
})
const onChangeAsync = vi.fn(async () => await changeResult)
const onBlurAsync = vi.fn(async () => await blurResult)
const source = new FieldApi({ form, name: 'source' })
const field = new FieldApi({
form,
name: 'email',
validators: {
onChangeListenTo: linked ? ['source'] : undefined,
onChangeAsync,
onBlurAsync,
},
})
source.mount()
field.mount()

if (linked) source.setValue('old-input')
else field.setValue('old-input')
await vi.runAllTimersAsync()
expect(onChangeAsync).toHaveBeenCalledTimes(1)
expect(field.state.meta.isValidating).toBe(true)

if (reset) {
form.reset()
expect(field.state.meta.isValidating).toBe(false)
expect(field.state.meta._pendingValidationsCount).toBe(0)
}

field.handleBlur()
await vi.runAllTimersAsync()
expect(onBlurAsync).toHaveBeenCalledTimes(1)
expect(field.state.meta.isValidating).toBe(true)
expect(field.state.meta._pendingValidationsCount).toBe(reset ? 1 : 2)

resolveChange(undefined)
await vi.runAllTimersAsync()
expect(field.state.meta.isValidating).toBe(true)
expect(field.state.meta._pendingValidationsCount).toBe(1)
expect(form.state.isFieldsValidating).toBe(true)

resolveBlur('New validation error')
await vi.runAllTimersAsync()
expect(field.state.meta.errorMap.onBlur).toBe('New validation error')
expect(field.state.meta.isValidating).toBe(false)
expect(field.state.meta._pendingValidationsCount).toBe(0)
expect(form.state.isFieldsValidating).toBe(false)
} finally {
vi.useRealTimers()
}
},
)

it.each([
{ linked: false, reset: false },
{ linked: false, reset: true },
{ linked: true, reset: false },
{ linked: true, reset: true },
])(
'should preserve pending validation when replacing a debounce (linked: $linked, reset: $reset)',
async ({ linked, reset }) => {
vi.useFakeTimers()
try {
const form = new FormApi({
defaultValues: { source: '', email: '' },
})
form.mount()
let resolveValidation!: (error: string | undefined) => void
const result = new Promise<string | undefined>((resolve) => {
resolveValidation = resolve
})
const onChangeAsync = vi.fn(async () => await result)
const source = new FieldApi({ form, name: 'source' })
const field = new FieldApi({
form,
name: 'email',
validators: {
onChangeListenTo: linked ? ['source'] : undefined,
onChangeAsyncDebounceMs: 1000,
onChangeAsync,
},
})
source.mount()
field.mount()

if (linked) source.setValue('first')
else field.setValue('first')
await vi.advanceTimersByTimeAsync(0)
expect(onChangeAsync).not.toHaveBeenCalled()
expect(field.state.meta._pendingValidationsCount).toBe(1)

if (reset) form.reset()
if (linked) source.setValue('second')
else field.setValue('second')
await vi.advanceTimersByTimeAsync(0)
expect(onChangeAsync).not.toHaveBeenCalled()
expect(field.state.meta.isValidating).toBe(true)
expect(field.state.meta._pendingValidationsCount).toBe(1)

await vi.advanceTimersByTimeAsync(1000)
expect(onChangeAsync).toHaveBeenCalledTimes(1)
expect(field.state.meta.isValidating).toBe(true)
expect(field.state.meta._pendingValidationsCount).toBe(1)

resolveValidation('New validation error')
await vi.runAllTimersAsync()
expect(field.state.meta.errorMap.onChange).toBe('New validation error')
expect(field.state.meta.isValidating).toBe(false)
expect(field.state.meta._pendingValidationsCount).toBe(0)
} finally {
vi.useRealTimers()
}
},
)

it.each([
{ linked: false, mode: 'no reset' },
{ linked: true, mode: 'no reset' },
{ linked: false, mode: 'reset only' },
{ linked: true, mode: 'reset only' },
{ linked: false, mode: 'reset and new validation' },
{ linked: true, mode: 'reset and new validation' },
])(
'should discard stale work awaiting form validation (linked: $linked, mode: $mode)',
async ({ linked, mode }) => {
vi.useFakeTimers()
try {
let resolveForm!: (result: undefined) => void
const formResult = new Promise<undefined>((resolve) => {
resolveForm = resolve
})
let formCalls = 0
const form = new FormApi({
defaultValues: { source: '', email: '' },
validators: {
onChangeAsync: async () => {
formCalls++
return formCalls === 1 ? await formResult : undefined
},
},
})
form.mount()

let resolveField!: (error: string | undefined) => void
const fieldResult = new Promise<string | undefined>((resolve) => {
resolveField = resolve
})
const onChangeAsync = vi.fn(async () => await fieldResult)
const source = new FieldApi({ form, name: 'source' })
const field = new FieldApi({
form,
name: 'email',
validators: {
onChangeListenTo: linked ? ['source'] : undefined,
onChangeAsync,
},
})
source.mount()
field.mount()

if (linked) source.setValue('old-input')
else field.setValue('old-input')
await vi.runAllTimersAsync()
expect(formCalls).toBe(1)
expect(onChangeAsync).not.toHaveBeenCalled()

if (mode !== 'no reset') form.reset()
if (mode === 'reset and new validation') {
if (linked) source.setValue('new-input')
else field.setValue('new-input')
await vi.runAllTimersAsync()
expect(formCalls).toBe(2)
expect(onChangeAsync).toHaveBeenCalledTimes(1)
expect(field.state.meta.isValidating).toBe(true)
}
const newController =
field.getInfo().validationMetaMap.onChange?.lastAbortController

resolveForm(undefined)
await vi.runAllTimersAsync()
expect(onChangeAsync).toHaveBeenCalledTimes(
mode === 'reset only' ? 0 : 1,
)
expect(field.state.meta._pendingValidationsCount).toBe(
mode === 'reset only' ? 0 : 1,
)
if (mode === 'reset and new validation') {
expect(newController?.signal.aborted).toBe(false)
expect(
field.getInfo().validationMetaMap.onChange?.lastAbortController,
).toBe(newController)
}

resolveField('Field error')
await vi.runAllTimersAsync()
expect(field.state.meta.isValidating).toBe(false)
expect(field.state.meta._pendingValidationsCount).toBe(0)
expect(field.state.meta.errorMap.onChange).toBe(
mode === 'reset only' ? undefined : 'Field error',
)
} finally {
vi.useRealTimers()
}
},
)

it('should run onChange on a linked field', () => {
const form = new FormApi({
defaultValues: {
Expand Down