Skip to content
5 changes: 5 additions & 0 deletions .changeset/true-impalas-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

fix race condition when listening to multiple Fields in onChangeListenTo
66 changes: 52 additions & 14 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
})
}
Comment thread
Pascalmh marked this conversation as resolved.

/**
* @private
*/
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,7 @@ export class FormApi<
isBlurred: false,
isDirty: false,
_arrayVersion: 0,
_pendingValidationsCount: 0,
...(existingFieldMeta ?? {}),
errorSourceMap: {
...(existingFieldMeta?.['errorSourceMap'] ?? {}),
Expand Down
1 change: 1 addition & 0 deletions packages/form-core/src/metaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const defaultFieldMeta: AnyFieldLikeMeta = {
errorMap: {},
errorSourceMap: {},
_arrayVersion: 0,
_pendingValidationsCount: 0,
}

export function metaHelper<
Expand Down
10 changes: 10 additions & 0 deletions packages/form-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe('field api', () => {
errorMap: {},
errorSourceMap: {},
_arrayVersion: 0,
_pendingValidationsCount: 0,
})
})

Expand Down
63 changes: 63 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
149 changes: 149 additions & 0 deletions packages/react-form/tests/onChangeListenTo.adapter.test.tsx
Original file line number Diff line number Diff line change
@@ -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<any> }) {
const isFieldsValidating = useSelector(store, (s) => s.isFieldsValidating)
return (
<span data-testid="isFieldsValidating">{String(isFieldsValidating)}</span>
)
}

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 (
<>
<form.Field
name="street"
validators={{
onChangeListenTo: ['houseNo', 'zipCode', 'city'],
onChangeAsyncDebounceMs: 300,
onChangeAsync: async () => {
await sleep(500)
validationFn()
return undefined
},
}}
children={(field) => (
<div>
<input
data-testid="street"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
<span data-testid="street-validating">
{String(field.state.meta.isValidating)}
</span>
</div>
)}
/>

<form.Field
name="houseNo"
children={(field) => (
<div>
<input
data-testid="houseNo"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
<span data-testid="houseNo-validating">
{String(field.state.meta.isValidating)}
</span>
</div>
)}
/>

<form.Field
name="zipCode"
children={(field) => (
<div>
<input
data-testid="zipCode"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
<span data-testid="zipCode-validating">
{String(field.state.meta.isValidating)}
</span>
</div>
)}
/>

<form.Field
name="city"
children={(field) => (
<div>
<input
data-testid="city"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
<span data-testid="city-validating">
{String(field.state.meta.isValidating)}
</span>
</div>
)}
/>

<DebugSubscribe store={form.store} />
</>
)
}

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

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