Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-concurrent-on-submit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

fix(form-core): prevent concurrent onSubmit invocations while a submission is in progress
13 changes: 10 additions & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2443,13 +2443,20 @@ export class FormApi<
const submitMetaArg =
submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta)

if (!this.state.canSubmit && !this._devtoolsSubmissionOverride) {
if (
this.state.isSubmitting ||
(!this.state.canSubmit && !this._devtoolsSubmissionOverride)
) {
// On re-submission (submissionAttempts > 1), skip the early return so
// 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
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.options.onSubmitInvalid?.({
value: this.state.values,
formApi: this,
Expand Down
48 changes: 48 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1991,6 +1991,54 @@ 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 not invoke onSubmit again while a submission is in progress when canSubmitWhenInvalid is true', async () => {
vi.useRealTimers()
const onSubmit = vi.fn(async () => {
await sleep(500)
})

const form = new FormApi({
defaultValues: { firstName: 'test' },
canSubmitWhenInvalid: true,
onSubmit,
})
form.mount()

const first = form.handleSubmit()
const second = form.handleSubmit()
await Promise.all([first, second])

expect(onSubmit).toHaveBeenCalledTimes(1)
expect(form.state.isSubmitSuccessful).toBe(true)
})

it('should run all types of async validation on fields during submit', async () => {
vi.useFakeTimers()

Expand Down