diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 78e7fa8a4..c2ef15aef 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1915,11 +1915,18 @@ export class FormApi< ) }) - if (!this.state.canSubmit) return - const submitMetaArg = submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) + if (!this.state.canSubmit) { + this.options.onSubmitInvalid?.({ + value: this.state.values, + formApi: this, + meta: submitMetaArg, + }) + return + } + this.baseStore.setState((d) => ({ ...d, isSubmitting: true })) const done = () => { diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 1725f02be..b786aef8e 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -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 }, @@ -3954,3 +3980,80 @@ 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) +})