diff --git a/.changeset/brown-cars-smell.md b/.changeset/brown-cars-smell.md new file mode 100644 index 000000000..6532c7351 --- /dev/null +++ b/.changeset/brown-cars-smell.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +fix(form-core): call `onSubmitInvalid` even when `canSubmit` is false diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 6afa0fdc9..611941b19 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -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 = () => { diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 3639ea7d6..901aa6dee 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 }, @@ -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 },