Skip to content

Commit 8551da0

Browse files
authored
fix(FieldApi): fix race condition when using onChangeListenTo
1 parent 93be6f0 commit 8551da0

3 files changed

Lines changed: 83 additions & 9 deletions

File tree

.changeset/true-impalas-behave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/form-core': patch
3+
---
4+
5+
fix race condition when listening to multiple Fields in onChangeListenTo

packages/form-core/src/FieldApi.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,18 +1854,25 @@ export class FieldApi<
18541854
// Check if there are actual async validators to run before setting isValidating
18551855
// This prevents unnecessary re-renders when there are no async validators
18561856
// See: https://github.com/TanStack/form/issues/1130
1857-
const hasAsyncValidators =
1858-
validates.some((v) => v.validate) ||
1859-
linkedFieldValidates.some((v) => v.validate)
1857+
const hasAsyncValidators = validates.some((v) => v.validate)
1858+
const linkedFieldsWithAsyncValidators = linkedFieldValidates.some(
1859+
(v) => v.validate,
1860+
)
1861+
? Array.from(
1862+
new Set(
1863+
linkedFieldValidates.filter((v) => v.validate).map((v) => v.field),
1864+
),
1865+
)
1866+
: []
18601867

18611868
if (hasAsyncValidators) {
18621869
if (!this.state.meta.isValidating) {
18631870
this.setMeta((prev) => ({ ...prev, isValidating: true }))
18641871
}
1872+
}
18651873

1866-
for (const linkedField of linkedFields) {
1867-
linkedField.setMeta((prev) => ({ ...prev, isValidating: true }))
1868-
}
1874+
for (const linkedField of linkedFieldsWithAsyncValidators) {
1875+
linkedField.setMeta((prev) => ({ ...prev, isValidating: true }))
18691876
}
18701877

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

1984-
for (const linkedField of linkedFields) {
1985-
linkedField.setMeta((prev) => ({ ...prev, isValidating: false }))
1986-
}
1992+
for (const linkedField of linkedFieldsWithAsyncValidators) {
1993+
linkedField.setMeta((prev) => ({ ...prev, isValidating: false }))
19871994
}
19881995

19891996
return results.filter(Boolean)

packages/form-core/tests/FormApi.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2991,6 +2991,68 @@ describe('form api', () => {
29912991
expect(passconfirmField.state.meta.errors.length).toBe(0)
29922992
})
29932993

2994+
it('should not leave linked fields stuck in isValidating when multiple setValue calls trigger concurrent async validation', async () => {
2995+
vi.useFakeTimers()
2996+
2997+
const validationFn = vi.fn()
2998+
2999+
const form = new FormApi({
3000+
defaultValues: {
3001+
street: '',
3002+
houseNo: '',
3003+
zipCode: '',
3004+
city: '',
3005+
},
3006+
})
3007+
3008+
form.mount()
3009+
3010+
const street = new FieldApi({
3011+
form,
3012+
name: 'street',
3013+
validators: {
3014+
onChangeListenTo: ['houseNo', 'zipCode', 'city'],
3015+
onChangeAsyncDebounceMs: 300,
3016+
onChangeAsync: async () => {
3017+
await sleep(500)
3018+
await validationFn()
3019+
return undefined
3020+
},
3021+
},
3022+
})
3023+
const houseNo = new FieldApi({ form, name: 'houseNo' })
3024+
const zipCode = new FieldApi({ form, name: 'zipCode' })
3025+
const city = new FieldApi({ form, name: 'city' })
3026+
3027+
street.mount()
3028+
houseNo.mount()
3029+
zipCode.mount()
3030+
city.mount()
3031+
3032+
// Simulate browser autofill: all fields set in rapid succession
3033+
street.setValue('Foo Street')
3034+
houseNo.setValue('2')
3035+
zipCode.setValue('12345')
3036+
city.setValue('Barrington')
3037+
3038+
// Run debounce + async validation
3039+
await vi.runAllTimersAsync()
3040+
3041+
expect.soft(validationFn).toHaveBeenCalledTimes(1)
3042+
3043+
expect.soft(street.getMeta().isValidating).toBe(false)
3044+
expect.soft(houseNo.getMeta().isValidating).toBe(false)
3045+
expect.soft(zipCode.getMeta().isValidating).toBe(false)
3046+
expect.soft(city.getMeta().isValidating).toBe(false)
3047+
3048+
expect.soft(form.state.isFieldsValidating).toBe(false)
3049+
expect.soft(form.state.isFieldsValid).toBe(true)
3050+
expect.soft(form.state.isValid).toBe(true)
3051+
expect.soft(form.state.canSubmit).toBe(true)
3052+
3053+
vi.useRealTimers()
3054+
})
3055+
29943056
it("should set field errors from the form's onMount validator", async () => {
29953057
const form = new FormApi({
29963058
defaultValues: {

0 commit comments

Comments
 (0)