diff --git a/.changeset/true-impalas-behave.md b/.changeset/true-impalas-behave.md new file mode 100644 index 000000000..b1d452bd4 --- /dev/null +++ b/.changeset/true-impalas-behave.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +fix race condition when listening to multiple Fields in onChangeListenTo diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index b1125ea09..86c976e79 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1373,6 +1373,38 @@ export class FieldApi< return { hasErrored } } + /** + * `@private` + * Starts tracking an async validation, incrementing the counter and setting isValidating if needed. + */ + private startValidation() { + this.setMeta((prev) => { + const newCount = (prev._pendingValidationsCount) + 1 + return { + ...prev, + _pendingValidationsCount: newCount, + isValidating: + newCount > 0 && !prev.isValidating ? true : prev.isValidating, + } + }) + } + + /** + * `@private` + * Ends tracking an async validation, decrementing the counter and clearing isValidating if no validations remain. + */ + private endValidation() { + this.setMeta((prev) => { + const newCount = Math.max(0, (prev._pendingValidationsCount) - 1) + return { + ...prev, + _pendingValidationsCount: newCount, + isValidating: + newCount === 0 && prev.isValidating ? false : prev.isValidating, + } + }) + } + /** * @private */ @@ -1436,19 +1468,22 @@ export class FieldApi< // Check if there are actual async validators to run before setting isValidating // This prevents unnecessary re-renders when there are no async validators // See: https://github.com/TanStack/form/issues/1130 - const hasAsyncValidators = - validates.some((v) => v.validate) || - linkedFieldValidates.some((v) => v.validate) + const hasAsyncValidators = validates.some((v) => v.validate) + const linkedFieldsWithAsyncValidators = Array.from( + new Set( + linkedFieldValidates.filter((v) => v.validate).map((v) => v.field), + ), + ) - if (hasAsyncValidators) { - if (!this.state.meta.isValidating) { - this.setMeta((prev) => ({ ...prev, isValidating: true })) + batch(() => { + if (hasAsyncValidators) { + this.startValidation() } - for (const linkedField of linkedFields) { - linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) + for (const linkedField of linkedFieldsWithAsyncValidators) { + linkedField.startValidation() } - } + }) const validateFieldAsyncFn = ( field: AnyFieldApi, @@ -1473,6 +1508,7 @@ export class FieldApi< rawError = await new Promise((rawResolve, rawReject) => { if (field.timeoutIds.validations[validateObj.cause]) { clearTimeout(field.timeoutIds.validations[validateObj.cause]!) + field.endValidation() } field.timeoutIds.validations[validateObj.cause] = setTimeout( @@ -1560,13 +1596,15 @@ export class FieldApi< } // Only reset isValidating if we set it to true earlier - if (hasAsyncValidators) { - this.setMeta((prev) => ({ ...prev, isValidating: false })) + batch(() => { + if (hasAsyncValidators) { + this.endValidation() + } - for (const linkedField of linkedFields) { - linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) + for (const linkedField of linkedFieldsWithAsyncValidators) { + linkedField.endValidation() } - } + }) return results.filter(Boolean) } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 5c9de3c1c..a38c5ff2f 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1143,6 +1143,7 @@ export class FormApi< isBlurred: false, isDirty: false, _arrayVersion: 0, + _pendingValidationsCount: 0, ...(existingFieldMeta ?? {}), errorSourceMap: { ...(existingFieldMeta?.['errorSourceMap'] ?? {}), diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index 9f5bdafc0..bab8a0630 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -20,6 +20,7 @@ export const defaultFieldMeta: AnyFieldLikeMeta = { errorMap: {}, errorSourceMap: {}, _arrayVersion: 0, + _pendingValidationsCount: 0, } export function metaHelper< diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index 62513afc2..c37ae8aab 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -554,6 +554,16 @@ export type FieldLikeMetaBase< */ isValidating: boolean + /** + * @private + * Counter for tracking active async validations to prevent race conditions + * when multiple validations finish at the same time. + * + * NOTE: This field is intentionally internal (prefixed with `_`) and is not + * part of the public API — do not read or rely on it from external code. + */ + _pendingValidationsCount: number + /** * @private a counter that is incremented every time a structural array * operation (push, insert, remove, swap, move, replace, clear) modifies diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 411885ce0..b74ce06b0 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -66,6 +66,7 @@ describe('field api', () => { errorMap: {}, errorSourceMap: {}, _arrayVersion: 0, + _pendingValidationsCount: 0, }) }) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 092d0ed6c..dc87ffca1 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -3031,6 +3031,69 @@ describe('form api', () => { expect(passconfirmField.state.meta.errors.length).toBe(0) }) + it('should not leave linked fields stuck in isValidating when multiple setValue calls trigger concurrent async validation', async () => { + vi.useFakeTimers() + const validationFn = vi.fn() + + const form = new FormApi({ + defaultValues: { + street: '', + houseNo: '', + zipCode: '', + city: '', + }, + }) + + form.mount() + + const street = new FieldApi({ + form, + name: 'street', + validators: { + onChangeListenTo: ['houseNo', 'zipCode', 'city'], + onChangeAsyncDebounceMs: 300, + onChangeAsync: async () => { + await sleep(500) + await validationFn() + return undefined + }, + }, + }) + const houseNo = new FieldApi({ form, name: 'houseNo' }) + const zipCode = new FieldApi({ form, name: 'zipCode' }) + const city = new FieldApi({ form, name: 'city' }) + + street.mount() + houseNo.mount() + zipCode.mount() + city.mount() + + // Simulate browser autofill: all fields set in rapid succession + street.setValue('Foo Street') + houseNo.setValue('2') + zipCode.setValue('12345') + city.setValue('Barrington') + + // Run debounce + async validation + try { + await vi.runAllTimersAsync() + + expect.soft(validationFn).toHaveBeenCalledTimes(1) + + expect.soft(street.getMeta().isValidating).toBe(false) + expect.soft(houseNo.getMeta().isValidating).toBe(false) + expect.soft(zipCode.getMeta().isValidating).toBe(false) + expect.soft(city.getMeta().isValidating).toBe(false) + + expect.soft(form.state.isFieldsValidating).toBe(false) + expect.soft(form.state.isFieldsValid).toBe(true) + expect.soft(form.state.isValid).toBe(true) + expect.soft(form.state.canSubmit).toBe(true) + } finally { + vi.useRealTimers() + } + }) + it("should set field errors from the form's onMount validator", async () => { const form = new FormApi({ defaultValues: { diff --git a/packages/react-form/tests/onChangeListenTo.adapter.test.tsx b/packages/react-form/tests/onChangeListenTo.adapter.test.tsx new file mode 100644 index 000000000..79defa0b4 --- /dev/null +++ b/packages/react-form/tests/onChangeListenTo.adapter.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import { useSelector } from '@tanstack/react-store' +import { StrictMode } from 'react' +import { useForm } from '../src/useForm' +import { sleep } from './utils' +import type { ReadonlyStore } from '@tanstack/react-store' + +function DebugSubscribe({ store }: { store: ReadonlyStore }) { + const isFieldsValidating = useSelector(store, (s) => s.isFieldsValidating) + return ( + {String(isFieldsValidating)} + ) +} + +describe('React adapter - onChangeListenTo race', () => { + it('does not leave linked fields stuck in isValidating when multiple rapid updates occur', async () => { + vi.useFakeTimers() + + const validationFn = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + street: '', + houseNo: '', + zipCode: '', + city: '', + }, + }) + + return ( + <> + { + await sleep(500) + validationFn() + return undefined + }, + }} + children={(field) => ( +
+ field.handleChange(e.target.value)} + /> + + {String(field.state.meta.isValidating)} + +
+ )} + /> + + ( +
+ field.handleChange(e.target.value)} + /> + + {String(field.state.meta.isValidating)} + +
+ )} + /> + + ( +
+ field.handleChange(e.target.value)} + /> + + {String(field.state.meta.isValidating)} + +
+ )} + /> + + ( +
+ field.handleChange(e.target.value)} + /> + + {String(field.state.meta.isValidating)} + +
+ )} + /> + + + + ) + } + + const { getByTestId } = render( + + + , + ) + + const street = getByTestId('street') as HTMLInputElement + const houseNo = getByTestId('houseNo') as HTMLInputElement + const zipCode = getByTestId('zipCode') as HTMLInputElement + const city = getByTestId('city') as HTMLInputElement + + // Simulate rapid updates (autofill) + fireEvent.change(street, { target: { value: 'Foo Street' } }) + fireEvent.change(houseNo, { target: { value: '2' } }) + fireEvent.change(zipCode, { target: { value: '12345' } }) + fireEvent.change(city, { target: { value: 'Barrington' } }) + + // Run debounce + async validation + await vi.runAllTimersAsync() + + expect(validationFn).toHaveBeenCalledTimes(1) + + // Verify validation flags are not stuck + const isFieldsValidating = getByTestId('isFieldsValidating').textContent + const streetValidating = getByTestId('street-validating').textContent + const houseValidating = getByTestId('houseNo-validating').textContent + const zipValidating = getByTestId('zipCode-validating').textContent + const cityValidating = getByTestId('city-validating').textContent + + expect(isFieldsValidating).toBe('false') + expect(streetValidating).toBe('false') + expect(houseValidating).toBe('false') + expect(zipValidating).toBe('false') + expect(cityValidating).toBe('false') + + vi.useRealTimers() + }) +})