From c70ec04778e3065bb2632b5e77b7618be1549dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20K=C3=BCsgen?= Date: Fri, 15 Aug 2025 17:02:22 +0200 Subject: [PATCH 1/3] fix(form-core): `form.onSubmitInvalid` not called when `canSubmit` is false Fixes #1696 --- packages/form-core/src/FormApi.ts | 11 ++++++++-- packages/form-core/tests/FormApi.spec.ts | 26 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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..b4766d9e9 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 }, From 7a8b6b289f9401e5114a0e8ae6135358ff22544e Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Fri, 22 Aug 2025 00:34:11 +0900 Subject: [PATCH 2/3] test: add validation and submission handling tests for invalid form states --- packages/form-core/tests/FormApi.spec.ts | 93 ++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index b4766d9e9..c42bec541 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -3980,3 +3980,96 @@ it('should accept formId and return it', () => { expect(form.formId).toEqual('age') }) + +it('should call onSubmitInvalid with current error state when canSubmit is false', async () => { + const onInvalid = vi.fn() + + const form = new FormApi({ + defaultValues: { name: '', email: '' }, + validators: { + onMount: ({ value }) => { + const errors: Record = {} + if (!value.name) errors.name = 'Name is required' + if (!value.email) errors.email = 'Email is required' + return Object.keys(errors).length > 0 ? errors : undefined + }, + }, + onSubmitInvalid: ({ value, formApi }) => { + onInvalid(value, formApi.state.errors) + }, + }) + + form.mount() + + new FieldApi({ form, name: 'name' }).mount() + new FieldApi({ form, name: 'email' }).mount() + + expect(form.state.canSubmit).toBe(false) + + await form.handleSubmit() + + expect(onInvalid).toHaveBeenCalledTimes(1) + expect(onInvalid).toHaveBeenCalledWith( + { name: '', email: '' }, + expect.any(Object), + ) +}) + +it('should not run submit validation when canSubmit is false', async () => { + const onSubmitValidator = vi.fn() + const onInvalid = vi.fn() + + const form = new FormApi({ + defaultValues: { name: '' }, + validators: { + onMount: ({ value }) => (!value.name ? 'Name required' : undefined), + onSubmit: ({ value }) => { + onSubmitValidator() + return !value.name ? 'Submit validation failed' : undefined + }, + }, + onSubmitInvalid: ({ value, formApi }) => { + onInvalid(value, formApi) + }, + }) + + form.mount() + new FieldApi({ form, name: 'name' }).mount() + + expect(form.state.canSubmit).toBe(false) + + await form.handleSubmit() + + expect(onSubmitValidator).not.toHaveBeenCalled() + expect(onInvalid).toHaveBeenCalledTimes(1) +}) + +it('should respect canSubmitWhenInvalid option and run validation even when canSubmit is false', async () => { + const onSubmitValidator = vi.fn() + const onInvalid = vi.fn() + + const form = new FormApi({ + defaultValues: { name: '' }, + canSubmitWhenInvalid: true, + validators: { + onMount: ({ value }) => (!value.name ? 'Name required' : undefined), + onSubmit: ({ value }) => { + onSubmitValidator() + return !value.name ? 'Submit validation failed' : undefined + }, + }, + onSubmitInvalid: ({ value, formApi }) => { + onInvalid(value, formApi) + }, + }) + + form.mount() + new FieldApi({ form, name: 'name' }).mount() + + expect(form.state.canSubmit).toBe(true) + + await form.handleSubmit() + + expect(onSubmitValidator).toHaveBeenCalledTimes(1) + expect(onInvalid).toHaveBeenCalledTimes(1) +}) From 15705d6987ac6571200e8b447dda2c79f348e9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20K=C3=BCsgen?= Date: Fri, 22 Aug 2025 09:14:40 +0200 Subject: [PATCH 3/3] test: improve validation and submission handling tests for invalid form states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Pascal Küsgen --- packages/form-core/tests/FormApi.spec.ts | 80 ++++++++++-------------- 1 file changed, 32 insertions(+), 48 deletions(-) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index c42bec541..b786aef8e 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -3981,95 +3981,79 @@ it('should accept formId and return it', () => { expect(form.formId).toEqual('age') }) -it('should call onSubmitInvalid with current error state when canSubmit is false', async () => { - const onInvalid = vi.fn() +it('should call onSubmitInvalid when submitted with onMount error', async () => { + const onInvalidSpy = vi.fn() const form = new FormApi({ - defaultValues: { name: '', email: '' }, + defaultValues: { name: '' }, validators: { - onMount: ({ value }) => { - const errors: Record = {} - if (!value.name) errors.name = 'Name is required' - if (!value.email) errors.email = 'Email is required' - return Object.keys(errors).length > 0 ? errors : undefined - }, - }, - onSubmitInvalid: ({ value, formApi }) => { - onInvalid(value, formApi.state.errors) + onMount: () => ({ name: 'Name is required' }), }, + onSubmitInvalid: () => onInvalidSpy(), }) - form.mount() - new FieldApi({ form, name: 'name' }).mount() - new FieldApi({ form, name: 'email' }).mount() + const field = new FieldApi({ form, name: 'name' }) + field.mount() expect(form.state.canSubmit).toBe(false) await form.handleSubmit() - expect(onInvalid).toHaveBeenCalledTimes(1) - expect(onInvalid).toHaveBeenCalledWith( - { name: '', email: '' }, - expect.any(Object), - ) + expect(onInvalidSpy).toHaveBeenCalledTimes(1) }) it('should not run submit validation when canSubmit is false', async () => { - const onSubmitValidator = vi.fn() - const onInvalid = vi.fn() + const onSubmitValidatorSpy = vi + .fn() + .mockImplementation(() => 'Submit validation failed') + const onInvalidSpy = vi.fn() const form = new FormApi({ defaultValues: { name: '' }, validators: { - onMount: ({ value }) => (!value.name ? 'Name required' : undefined), - onSubmit: ({ value }) => { - onSubmitValidator() - return !value.name ? 'Submit validation failed' : undefined - }, - }, - onSubmitInvalid: ({ value, formApi }) => { - onInvalid(value, formApi) + onMount: () => 'Name required', + onSubmit: () => onSubmitValidatorSpy, }, + onSubmitInvalid: () => onInvalidSpy(), }) - form.mount() - new FieldApi({ form, name: 'name' }).mount() + + const field = new FieldApi({ form, name: 'name' }) + field.mount() expect(form.state.canSubmit).toBe(false) await form.handleSubmit() - expect(onSubmitValidator).not.toHaveBeenCalled() - expect(onInvalid).toHaveBeenCalledTimes(1) + expect(onSubmitValidatorSpy).not.toHaveBeenCalled() + expect(onInvalidSpy).toHaveBeenCalledTimes(1) }) it('should respect canSubmitWhenInvalid option and run validation even when canSubmit is false', async () => { - const onSubmitValidator = vi.fn() - const onInvalid = vi.fn() + const onSubmitValidatorSpy = vi + .fn() + .mockImplementation(() => 'Submit validation failed') + const onInvalidSpy = vi.fn() const form = new FormApi({ defaultValues: { name: '' }, canSubmitWhenInvalid: true, validators: { - onMount: ({ value }) => (!value.name ? 'Name required' : undefined), - onSubmit: ({ value }) => { - onSubmitValidator() - return !value.name ? 'Submit validation failed' : undefined - }, - }, - onSubmitInvalid: ({ value, formApi }) => { - onInvalid(value, formApi) + onMount: () => 'Name required', + onSubmit: () => onSubmitValidatorSpy(), }, + onSubmitInvalid: () => onInvalidSpy(), }) - form.mount() - new FieldApi({ form, name: 'name' }).mount() + + const field = new FieldApi({ form, name: 'name' }) + field.mount() expect(form.state.canSubmit).toBe(true) await form.handleSubmit() - expect(onSubmitValidator).toHaveBeenCalledTimes(1) - expect(onInvalid).toHaveBeenCalledTimes(1) + expect(onSubmitValidatorSpy).toHaveBeenCalledTimes(1) + expect(onInvalidSpy).toHaveBeenCalledTimes(1) })