diff --git a/.changeset/fix-concurrent-on-submit.md b/.changeset/fix-concurrent-on-submit.md new file mode 100644 index 0000000000..5264c47e44 --- /dev/null +++ b/.changeset/fix-concurrent-on-submit.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +fix(form-core): prevent concurrent onSubmit invocations while a submission is in progress diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 4400017d30..91460f3d52 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2448,8 +2448,12 @@ export class FormApi< // validateAllFields can re-run and clear stale field errors (e.g. from a // previous onBlur validation that is no longer relevant). The // isFieldsValid check below will call onSubmitInvalid if the form is - // still invalid after re-validation. - if (this.baseStore.state.submissionAttempts <= 1) { + // still invalid after re-validation. A submission that is already in + // progress always returns early so onSubmit cannot run concurrently. + if ( + this.baseStore.state.submissionAttempts <= 1 || + this.state.isSubmitting + ) { this.options.onSubmitInvalid?.({ value: this.state.values, formApi: this, diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index c57bb044e8..943d3a53da 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -1991,6 +1991,33 @@ describe('form api', () => { expect(onSubmitInvalid).toHaveBeenCalledTimes(1) // not called again }) + it('should not invoke onSubmit again while a submission is in progress', async () => { + vi.useRealTimers() + // Regression: once submissionAttempts > 1 the re-submission guard skipped + // the canSubmit early return, so each submit call during a pending + // submission invoked onSubmit again, causing concurrent submissions. + const onSubmit = vi.fn(async () => { + await sleep(500) + }) + const onSubmitInvalid = vi.fn() + + const form = new FormApi({ + defaultValues: { firstName: 'test' }, + onSubmit, + onSubmitInvalid, + }) + form.mount() + + const first = form.handleSubmit() + // Further submit attempts while the first submission is still pending + const second = form.handleSubmit() + const third = form.handleSubmit() + await Promise.all([first, second, third]) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmitInvalid).toHaveBeenCalledTimes(2) + }) + it('should run all types of async validation on fields during submit', async () => { vi.useFakeTimers()