Skip to content
5 changes: 5 additions & 0 deletions .changeset/brown-cars-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

fix(form-core): call `onSubmitInvalid` even when `canSubmit` is false
11 changes: 9 additions & 2 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2014,11 +2014,18 @@ export class FormApi<
)
})

if (!this.state.canSubmit && !this._devtoolsSubmissionOverride) return

const submitMetaArg =
submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta)

if (!this.state.canSubmit && !this._devtoolsSubmissionOverride) {
this.options.onSubmitInvalid?.({
value: this.state.values,
formApi: this,
meta: submitMetaArg,
})
return
}

this.baseStore.setState((d) => ({ ...d, isSubmitting: true }))

const done = () => {
Expand Down
103 changes: 103 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3156,6 +3156,32 @@ describe('form api', () => {
await form.handleSubmit()
})

it('should call onSubmitInvalid when submitting while canSubmit is false (e.g., onMount error present)', async () => {
const onInvalid = vi.fn()

const form = new FormApi({
defaultValues: { name: '' },
validators: {
onMount: ({ value }) => (!value.name ? 'Name required' : undefined),
},
onSubmitInvalid: ({ value, formApi }) => {
onInvalid(value, formApi)
},
})

form.mount()

// Mount a field to participate in touched/dirty state
new FieldApi({ form, name: 'name' }).mount()

// With an onMount error present, the form is invalid and cannot submit
expect(form.state.canSubmit).toBe(false)

await form.handleSubmit()

expect(onInvalid).toHaveBeenCalledTimes(1)
})

it('should pass the handleSubmit default meta data to onSubmitInvalid', async () => {
const form = new FormApi({
onSubmitMeta: { dinosaur: 'Frank' } as { dinosaur: string },
Expand Down Expand Up @@ -3955,6 +3981,83 @@ it('should accept formId and return it', () => {
expect(form.formId).toEqual('age')
})

it('should call onSubmitInvalid when submitted with onMount error', async () => {
const onInvalidSpy = vi.fn()

const form = new FormApi({
defaultValues: { name: '' },
validators: {
onMount: () => ({ name: 'Name is required' }),
},
onSubmitInvalid: () => onInvalidSpy(),
})
form.mount()

const field = new FieldApi({ form, name: 'name' })
field.mount()

expect(form.state.canSubmit).toBe(false)

await form.handleSubmit()

expect(onInvalidSpy).toHaveBeenCalledTimes(1)
})

it('should not run submit validation when canSubmit is false', async () => {
const onSubmitValidatorSpy = vi
.fn()
.mockImplementation(() => 'Submit validation failed')
const onInvalidSpy = vi.fn()

const form = new FormApi({
defaultValues: { name: '' },
validators: {
onMount: () => 'Name required',
onSubmit: () => onSubmitValidatorSpy,
},
onSubmitInvalid: () => onInvalidSpy(),
})
form.mount()

const field = new FieldApi({ form, name: 'name' })
field.mount()

expect(form.state.canSubmit).toBe(false)

await form.handleSubmit()

expect(onSubmitValidatorSpy).not.toHaveBeenCalled()
expect(onInvalidSpy).toHaveBeenCalledTimes(1)
})

it('should respect canSubmitWhenInvalid option and run validation even when canSubmit is false', async () => {
const onSubmitValidatorSpy = vi
.fn()
.mockImplementation(() => 'Submit validation failed')
const onInvalidSpy = vi.fn()

const form = new FormApi({
defaultValues: { name: '' },
canSubmitWhenInvalid: true,
validators: {
onMount: () => 'Name required',
onSubmit: () => onSubmitValidatorSpy(),
},
onSubmitInvalid: () => onInvalidSpy(),
})
form.mount()

const field = new FieldApi({ form, name: 'name' })
field.mount()

expect(form.state.canSubmit).toBe(true)

await form.handleSubmit()

expect(onSubmitValidatorSpy).toHaveBeenCalledTimes(1)
expect(onInvalidSpy).toHaveBeenCalledTimes(1)
})

it('should generate a formId if not provided', () => {
const form = new FormApi({
defaultValues: { age: 0 },
Expand Down
Loading