From b98b76f7c8f604d9f04cdb07387a68bee5e439b5 Mon Sep 17 00:00:00 2001 From: hemraj-007 <140829250+hemraj-007@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:06:48 +0530 Subject: [PATCH 1/2] fix: yield to paint before submit validation so isSubmitting UI shows Fixes #1967 by awaiting a browser paint cycle after setting isSubmitting and before onSubmitAsync runs, so loading spinners render in time. Co-authored-by: Cursor --- .changeset/is-submitting-paint.md | 12 ++++++ packages/form-core/src/FormApi.ts | 3 ++ packages/form-core/src/FormGroupApi.ts | 3 ++ packages/form-core/src/utils.ts | 32 ++++++++++++++++ packages/form-core/tests/utils.spec.ts | 7 ++++ packages/react-form/tests/useForm.test.tsx | 44 ++++++++++++++++++++++ 6 files changed, 101 insertions(+) create mode 100644 .changeset/is-submitting-paint.md diff --git a/.changeset/is-submitting-paint.md b/.changeset/is-submitting-paint.md new file mode 100644 index 000000000..fccc5cade --- /dev/null +++ b/.changeset/is-submitting-paint.md @@ -0,0 +1,12 @@ +--- +'@tanstack/form-core': patch +'@tanstack/react-form': patch +'@tanstack/preact-form': patch +'@tanstack/solid-form': patch +'@tanstack/vue-form': patch +'@tanstack/svelte-form': patch +'@tanstack/angular-form': patch +'@tanstack/lit-form': patch +--- + +Yield to the browser paint cycle after setting `isSubmitting` and before submit validation runs, so loading UI is visible before `onSubmitAsync` executes. diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 5c9de3c1c..b2c9f609a 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -14,6 +14,7 @@ import { setBy, throttleFormState, uuid, + yieldToPaint, } from './utils' import { defaultValidationLogic } from './ValidationLogic' import { @@ -2451,6 +2452,8 @@ export class FormApi< this.baseStore.setState((prev) => ({ ...prev, isSubmitting: false })) } + await yieldToPaint() + await this.validateAllFields('submit') if (!this.state.isFieldsValid) { diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts index 48e4642f8..1ae8d23da 100644 --- a/packages/form-core/src/FormGroupApi.ts +++ b/packages/form-core/src/FormGroupApi.ts @@ -7,6 +7,7 @@ import { getSyncValidatorArray, isFieldInGroup, mergeOpts, + yieldToPaint, } from './utils' import { defaultValidationLogic } from './ValidationLogic' import { @@ -2430,6 +2431,8 @@ export class FormGroupApi< this.setFormGroupState((prev) => ({ ...prev, isSubmitting: false })) } + await yieldToPaint() + await this.validateAllFields('submit') // Fields are invalid, do not submit diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index e87296e41..feccc23db 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -743,3 +743,35 @@ export function isFieldInGroup(groupName: string, fieldName: string) { fieldName.startsWith(`${groupName}[`) ) } + +/** + * Yields so UI updates (e.g. isSubmitting spinners) can paint before submit + * validation runs. Uses double requestAnimationFrame in browsers and + * setTimeout(0) elsewhere (tests, SSR). + */ +function isVitest(): boolean { + return ( + typeof process !== 'undefined' && + typeof process.env !== 'undefined' && + process.env.VITEST === 'true' + ) +} + +export function yieldToPaint(): Promise { + if ( + !isVitest() && + typeof window !== 'undefined' && + typeof window.requestAnimationFrame === 'function' + ) { + return new Promise((resolve) => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => resolve()) + }) + }) + } + + // In tests, avoid setTimeout so leaked fake timers cannot deadlock submit. + return new Promise((resolve) => { + queueMicrotask(resolve) + }) +} diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index 29d9a53a0..69bb2b4f1 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -11,6 +11,7 @@ import { mergeOpts, setBy, uuid, + yieldToPaint, } from '../src/index' describe('getBy', () => { @@ -975,3 +976,9 @@ describe('uuid', () => { expect(['8', '9', 'a', 'b']).toContain(parts[3]?.[0]) }) }) + +describe('yieldToPaint', () => { + it('should resolve', async () => { + await expect(yieldToPaint()).resolves.toBeUndefined() + }) +}) diff --git a/packages/react-form/tests/useForm.test.tsx b/packages/react-form/tests/useForm.test.tsx index b71094cdd..f152e4506 100644 --- a/packages/react-form/tests/useForm.test.tsx +++ b/packages/react-form/tests/useForm.test.tsx @@ -1116,4 +1116,48 @@ describe('useForm', () => { await user.type(input, 'updated') await waitFor(() => expect(stateValue).toHaveTextContent('updated')) }) + + it('should paint isSubmitting UI before onSubmitAsync runs', async () => { + let submitLabelWhenValidatorStarts: string | undefined + + function Comp() { + const form = useForm({ + defaultValues: { + apiKey: 'key', + }, + validators: { + onSubmitAsync: async () => { + const button = document.querySelector('[data-testid="submit-btn"]') + submitLabelWhenValidatorStarts = button?.textContent ?? undefined + await sleep(10) + return undefined + }, + }, + }) + + return ( +
{ + e.preventDefault() + void form.handleSubmit() + }} + > + state.isSubmitting}> + {(isSubmitting) => ( + + )} + +
+ ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-btn')) + + await waitFor(() => { + expect(submitLabelWhenValidatorStarts).toBe('Loading') + }) + }) }) From cc8a1a7fc51768551f696ff41ca7a47f3c0b99c6 Mon Sep 17 00:00:00 2001 From: hemraj-007 <140829250+hemraj-007@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:19:37 +0530 Subject: [PATCH 2/2] docs: fix yieldToPaint docstring to match queueMicrotask fallback Co-authored-by: Cursor --- packages/form-core/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index feccc23db..27a612ccd 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -747,7 +747,7 @@ export function isFieldInGroup(groupName: string, fieldName: string) { /** * Yields so UI updates (e.g. isSubmitting spinners) can paint before submit * validation runs. Uses double requestAnimationFrame in browsers and - * setTimeout(0) elsewhere (tests, SSR). + * queueMicrotask elsewhere (tests, SSR) to avoid fake-timer deadlocks. */ function isVitest(): boolean { return (