Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to prepend a _ to make it clearer it's not for user consumption. Otherwise, people might get the idea that this is some tracked state for the amount of validation attempts or something similar.

Suggested change
validationCount: number
_pendingValidationsCount: 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()
}

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