diff --git a/.changeset/mosaic-form-cancelled-reverification.md b/.changeset/mosaic-form-cancelled-reverification.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-form-cancelled-reverification.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index c64f2589c4b..1baa6342e92 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -233,7 +233,8 @@ One path, so every error a user sees goes through the `errors.*` catalog: error as a `SaveError` with each error routed to the field it names. The form machine turns a `SaveError` into field and global errors. A controller can also reject a submit with `FormSubmitError` (`components/form`) to set field errors - itself. + itself. A submit that rejects because the user cancelled a reverification + is not an error: the form returns to editing with its values and no message. - **Owners hold an `ErrorDescription`.** It is either a `LocalizableError` (Clerk gave a `code`) or an `UnlocalizableError` (`{ cause }`, the original failure). `toLocalizableError` logs the second as diff --git a/packages/mosaic/src/components/form/__tests__/use-form.test.ts b/packages/mosaic/src/components/form/__tests__/use-form.test.ts index 777feb20dc3..7a785cffe8d 100644 --- a/packages/mosaic/src/components/form/__tests__/use-form.test.ts +++ b/packages/mosaic/src/components/form/__tests__/use-form.test.ts @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; @@ -130,7 +131,7 @@ describe('useForm', () => { it('shows the generic message and logs the cause for an unrecognized rejection', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const causes: unknown[] = [new Error('Nope'), 'boom']; + const causes: unknown[] = [new Error('Nope'), 'boom', undefined, null, 0]; for (const cause of causes) { const { result } = renderHook(() => useForm({ @@ -151,6 +152,37 @@ describe('useForm', () => { log.mockRestore(); }); + it('returns to editing with the draft and no message when a reverification is cancelled', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const outcomes: Array<() => Promise> = [ + () => Promise.reject(new FormSubmitError({ message: 'Username is taken.' })), + () => + Promise.reject( + new ClerkRuntimeError('User cancelled attempted verification', { code: 'reverification_cancelled' }), + ), + ]; + const { result } = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => (outcomes.shift() ?? resolved)() }), + ); + act(() => result.current.setValue('username', 'alex')); + await act(async () => { + result.current.submit(); + await tick(); + }); + expect(result.current.error).toBe('Username is taken.'); + + await act(async () => { + result.current.submit(); + await tick(); + }); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.values).toEqual({ username: 'alex' }); + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); + it('shows errorFallback instead of the generic message', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); const rejections = [new Error('Nope'), new SaveError({})]; diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 679221b5a7a..8124e23e1f9 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,3 +1,5 @@ +import { isReverificationCancelledError } from '@clerk/shared/error'; + import type { LocalizableError } from '../../localization'; import { setup } from '../../machine/setup'; import type { TransitionResult } from '../../machine/types'; @@ -251,10 +253,17 @@ export function createFormMachine(deps: FormDeps ({ error: undefined })), }, - onError: { - target: 'editing', - actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), - }, + onError: [ + { + guard: (_, e) => Boolean(e.error) && isReverificationCancelledError(e.error), + target: 'editing', + actions: assign(() => ({ error: undefined })), + }, + { + target: 'editing', + actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), + }, + ], }), }, },