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/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
70 changes: 57 additions & 13 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,12 @@ export type FieldMetaBase<
* A flag indicating whether the field is currently being validated.
*/
isValidating: boolean
/**
* @private
* Counter for tracking active async validations to prevent race conditions
* when multiple validations finish at the same time.
*/
validationCount: number
}

export type AnyFieldMetaBase = FieldMetaBase<
Expand Down Expand Up @@ -1791,6 +1797,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.validationCount + 1
return {
...prev,
validationCount: 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.validationCount - 1)
return {
...prev,
validationCount: newCount,
isValidating:
newCount === 0 && prev.isValidating ? false : prev.isValidating,
}
})
}
Comment thread
Pascalmh marked this conversation as resolved.

/**
* @private
*/
Expand Down Expand Up @@ -1854,18 +1892,23 @@ 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 = linkedFieldValidates.some(
(v) => v.validate,
)
? 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 }))
}
this.startValidation()
}

for (const linkedField of linkedFields) {
linkedField.setMeta((prev) => ({ ...prev, isValidating: true }))
}
for (const linkedField of linkedFieldsWithAsyncValidators) {
linkedField.startValidation()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const validateFieldAsyncFn = (
Expand All @@ -1891,6 +1934,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 @@ -1979,11 +2023,11 @@ export class FieldApi<

// Only reset isValidating if we set it to true earlier
if (hasAsyncValidators) {
this.setMeta((prev) => ({ ...prev, isValidating: false }))
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 @@ -1041,6 +1041,7 @@ export class FormApi<
isValidating: false,
isBlurred: false,
isDirty: false,
validationCount: 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 @@ -19,6 +19,7 @@ export const defaultFieldMeta: AnyFieldMeta = {
errors: [],
errorMap: {},
errorSourceMap: {},
validationCount: 0,
}

export function metaHelper<
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 @@ -65,6 +65,7 @@ describe('field api', () => {
errors: [],
errorMap: {},
errorSourceMap: {},
validationCount: 0,
})
})

Expand Down
62 changes: 62 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2991,6 +2991,68 @@ 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
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)

vi.useRealTimers()
})

it("should set field errors from the form's onMount validator", async () => {
const form = new FormApi({
defaultValues: {
Expand Down
Loading