From 44f43b0a4a708a9ffc5983df03004376e0f8fbf4 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sat, 3 Jan 2026 21:35:31 +0800 Subject: [PATCH 1/2] fix(form-core): set isValidating synchronously before debounce delay When async validation is scheduled with debounce, the `isValidating` flag was being set AFTER an await, causing a gap where callers would see `isValidating: false` during the debounce period. This caused form.canSubmit to be true when it should be false. The fix moves the `isValidating = true` assignment to happen synchronously, before any await, when async validators are detected. Fixes #1833 --- packages/form-core/src/FieldApi.ts | 24 +++++---- packages/form-core/tests/FieldApi.spec.ts | 65 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 1d5ba3096..e56f27916 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1735,6 +1735,14 @@ export class FieldApi< this.form.options.validationLogic || defaultValidationLogic, }) + // Check if this field has its own async validators BEFORE any await + // This ensures isValidating is set synchronously when async validation is scheduled + // See: https://github.com/TanStack/form/issues/1833 + const hasOwnAsyncValidators = validates.some((v) => v.validate) + if (hasOwnAsyncValidators && !this.state.meta.isValidating) { + this.setMeta((prev) => ({ ...prev, isValidating: true })) + } + // Get the field-specific error messages that are coming from the form's validator const asyncFormValidationResults = await formValidationResultPromise @@ -1766,18 +1774,16 @@ export class FieldApi< const validatesPromises: Promise[] = [] const linkedPromises: Promise[] = [] - // Check if there are actual async validators to run before setting isValidating + // Check if there are actual async validators to run (including linked fields) // 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) - - if (hasAsyncValidators) { - if (!this.state.meta.isValidating) { - this.setMeta((prev) => ({ ...prev, isValidating: true })) - } + const hasLinkedAsyncValidators = linkedFieldValidates.some( + (v) => v.validate, + ) + const hasAsyncValidators = hasOwnAsyncValidators || hasLinkedAsyncValidators + // Set isValidating for linked fields (own field already set above before await) + if (hasLinkedAsyncValidators) { for (const linkedField of linkedFields) { linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index abca29868..67b435e4d 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2936,4 +2936,69 @@ describe('edge cases and error handling', () => { field.handleChange(undefined) expect(field.state.value).toBeUndefined() }) + + // Test for https://github.com/TanStack/form/issues/1833 + it('should keep isValidating true during async debounce period when sync validation passes', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + // Sync validator that passes for 'error' value + onChange: ({ value }) => { + if (value.length < 3) return 'Too short' + return undefined + }, + // Async validator with 500ms debounce + onChangeAsyncDebounceMs: 500, + onChangeAsync: async ({ value }) => { + await sleep(100) + if (value === 'error') return 'Server error' + return undefined + }, + }, + }) + + field.mount() + + // Initially not validating + expect(field.getMeta().isValidating).toBe(false) + expect(form.state.canSubmit).toBe(true) + + // Touch the field first + field.setMeta((prev) => ({ ...prev, isTouched: true })) + + // Type a value that passes sync validation + field.setValue('error') + + // Immediately after setValue, isValidating should be true + // because async validation is pending (even though debounce hasn't elapsed) + expect(field.getMeta().isValidating).toBe(true) + + // Form's canSubmit should be false during validation + expect(form.state.canSubmit).toBe(false) + + // Advance past debounce but not past async validator + await vi.advanceTimersByTimeAsync(500) + + // Still validating (async validator is running) + expect(field.getMeta().isValidating).toBe(true) + expect(form.state.canSubmit).toBe(false) + + // Finish all timers + await vi.runAllTimersAsync() + + // Now validation is complete + expect(field.getMeta().isValidating).toBe(false) + expect(field.getMeta().errors).toContain('Server error') + }) }) From b9dc82fe1e8938e9432940dd6317cf28c3563cbe Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sat, 3 Jan 2026 22:11:32 +0800 Subject: [PATCH 2/2] fix: preserve isValidating behavior for linked fields and add test cleanup - Set current field's isValidating when linked fields have async validators (preserves original behavior) - Add vi.useRealTimers() cleanup to test --- packages/form-core/src/FieldApi.ts | 7 ++++++- packages/form-core/tests/FieldApi.spec.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index e56f27916..43d94385f 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1782,8 +1782,13 @@ export class FieldApi< ) const hasAsyncValidators = hasOwnAsyncValidators || hasLinkedAsyncValidators - // Set isValidating for linked fields (own field already set above before await) + // Set isValidating for linked fields and current field when linked fields are validating + // This preserves original behavior where current field shows validating + // when any of its linked fields are validating if (hasLinkedAsyncValidators) { + if (!this.state.meta.isValidating) { + this.setMeta((prev) => ({ ...prev, isValidating: true })) + } for (const linkedField of linkedFields) { linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 67b435e4d..e3e5374c5 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -3000,5 +3000,7 @@ describe('edge cases and error handling', () => { // Now validation is complete expect(field.getMeta().isValidating).toBe(false) expect(field.getMeta().errors).toContain('Server error') + + vi.useRealTimers() }) })