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..27a612ccd 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 + * queueMicrotask elsewhere (tests, SSR) to avoid fake-timer deadlocks. + */ +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') + }) + }) })