Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 16 additions & 9 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1854,18 +1854,25 @@ 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 }))
}
}

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

const validateFieldAsyncFn = (
Expand Down Expand Up @@ -1980,10 +1987,10 @@ export class FieldApi<
// Only reset isValidating if we set it to true earlier
if (hasAsyncValidators) {
this.setMeta((prev) => ({ ...prev, isValidating: false }))
}

for (const linkedField of linkedFields) {
linkedField.setMeta((prev) => ({ ...prev, isValidating: false }))
}
for (const linkedField of linkedFieldsWithAsyncValidators) {
linkedField.setMeta((prev) => ({ ...prev, isValidating: false }))
}

return results.filter(Boolean)
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