diff --git a/.changeset/true-impalas-behave.md b/.changeset/true-impalas-behave.md new file mode 100644 index 000000000..b1d452bd4 --- /dev/null +++ b/.changeset/true-impalas-behave.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +fix race condition when listening to multiple Fields in onChangeListenTo diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 7d4a37b4c..86a9cddca 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -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< @@ -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, + } + }) + } + /** * @private */ @@ -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 = ( @@ -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( @@ -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) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 2317fb2b9..10f09d705 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1041,6 +1041,7 @@ export class FormApi< isValidating: false, isBlurred: false, isDirty: false, + validationCount: 0, ...(existingFieldMeta ?? {}), errorSourceMap: { ...(existingFieldMeta?.['errorSourceMap'] ?? {}), diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index a990c0c0a..37de125e2 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -19,6 +19,7 @@ export const defaultFieldMeta: AnyFieldMeta = { errors: [], errorMap: {}, errorSourceMap: {}, + validationCount: 0, } export function metaHelper< diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 1fa3d4d65..a6d795967 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -65,6 +65,7 @@ describe('field api', () => { errors: [], errorMap: {}, errorSourceMap: {}, + validationCount: 0, }) }) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index eccd2e544..3be0ff00e 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -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: {