From d468e1a0e6a4b9d32e03c1045e8f360c2a7ca208 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 30 Oct 2026 22:54:45 -0600 Subject: [PATCH 1/5] fix(mosaic): preserve OTP preparation across method navigation --- .changeset/fresh-otp-preparation.md | 5 + .../reverification-preparation.test.ts | 155 ++++++++++++++++++ .../__tests__/reverification.feature.test.tsx | 24 +++ .../reverification.controller.ts | 77 ++++++--- 4 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 .changeset/fresh-otp-preparation.md create mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts diff --git a/.changeset/fresh-otp-preparation.md b/.changeset/fresh-otp-preparation.md new file mode 100644 index 00000000000..785c106fb9b --- /dev/null +++ b/.changeset/fresh-otp-preparation.md @@ -0,0 +1,5 @@ +--- +"@clerk/mosaic": patch +--- + +Preserve verification code delivery errors when returning from the method picker in profile reverification, allowing an immediate resend after a failed delivery. diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts b/packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts new file mode 100644 index 00000000000..cca5b1ab564 --- /dev/null +++ b/packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts @@ -0,0 +1,155 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../../__tests__/async'; +import { clerkApiError } from '../../../__tests__/clerk-errors'; +import { useReverificationController } from '../reverification.controller'; +import type { ReverificationActiveModel, ReverificationModel } from '../reverification.model'; +import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; + +const email: ReverificationMethod = { + id: 'email', + stage: 'first', + strategy: 'email_code', + emailAddressId: 'idn_email', + identifier: 'alice@example.com', +}; +const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; +const challenge: ReverificationResult = { + status: 'needs_first_factor', + methods: [email, password], + startingMethod: email, +}; + +async function preparing() { + const preparation = deferred(); + const model: ReverificationActiveModel = { + status: 'active', + supportEmail: '', + start: vi.fn().mockResolvedValue(challenge), + prepare: vi.fn().mockReturnValueOnce(preparation.promise).mockResolvedValue(undefined), + attempt: vi.fn().mockResolvedValue({ ...challenge, status: 'complete' }), + finish: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn(), + }; + const hook = renderHook((props: ReverificationModel) => useReverificationController(props), { + initialProps: model, + }); + const ready = () => { + const controller = hook.result.current; + if (controller.status !== 'ready') { + throw new Error(`Expected ready, received ${controller.status}`); + } + return controller; + }; + await waitFor(() => expect(model.prepare).toHaveBeenCalledTimes(1)); + return { ...hook, model, preparation, ready }; +} + +describe('OTP preparation while navigating', () => { + it.each(['method-picker', 'help'] as const)( + 'retains failure while viewing %s and allows an immediate retry', + async step => { + const { model, preparation, ready } = await preparing(); + act(() => ready().onShowMethods()); + if (step === 'help') { + act(() => ready().onShowHelp()); + } + await act(() => preparation.reject(clerkApiError('preparation_failed', 'Could not send your code'))); + expect(ready().step).toBe(step); + if (step === 'help') { + act(() => ready().onBack()); + expect(ready().step).toBe('method-picker'); + } + act(() => ready().onBack()); + expect(ready().step).toBe('otp'); + expect(ready().errorMessage).toBe('Could not send your code'); + expect(ready().canResend).toBe(true); + await act(() => ready().onResend()); + expect(model.prepare).toHaveBeenCalledTimes(2); + expect(ready().errorMessage).toBeUndefined(); + expect(ready().canResend).toBe(false); + }, + ); + + it('keeps a successful send and its cooldown when returning from the picker', async () => { + const { model, preparation, ready } = await preparing(); + act(() => ready().onShowMethods()); + await act(() => preparation.resolve()); + expect(ready().step).toBe('method-picker'); + act(() => ready().onBack()); + expect(ready().step).toBe('otp'); + expect(ready().errorMessage).toBeUndefined(); + expect(ready().canResend).toBe(false); + act(() => ready().onResend()); + expect(model.prepare).toHaveBeenCalledTimes(1); + }); + + it.each(['success', 'failure'] as const)( + 'holds a submit after Back until preparation settles with %s', + async outcome => { + const { model, preparation, ready } = await preparing(); + act(() => ready().onShowMethods()); + act(() => ready().onBack()); + act(() => ready().onValueChange('123456')); + act(() => ready().onSubmit()); + expect(model.attempt).not.toHaveBeenCalled(); + expect(model.prepare).toHaveBeenCalledTimes(1); + if (outcome === 'failure') { + await act(() => preparation.reject(clerkApiError('preparation_failed', 'Could not send your code'))); + expect(model.attempt).not.toHaveBeenCalled(); + expect(ready().errorMessage).toBe('Could not send your code'); + expect(ready().canResend).toBe(true); + } else { + await act(() => preparation.resolve()); + expect(model.attempt).toHaveBeenCalledExactlyOnceWith(email, '123456'); + } + }, + ); + + it('returns from help to the pending factor without sending another code', async () => { + const { model, preparation, ready } = await preparing(); + act(() => ready().onShowHelp()); + expect(ready().step).toBe('help'); + act(() => ready().onBack()); + expect(ready().step).toBe('otp'); + await act(() => preparation.reject(clerkApiError('preparation_failed', 'Could not send your code'))); + expect(ready().errorMessage).toBe('Could not send your code'); + expect(ready().canResend).toBe(true); + expect(model.prepare).toHaveBeenCalledTimes(1); + }); + + it('discards a queued submit when opening the picker', async () => { + const { model, preparation, ready } = await preparing(); + act(() => ready().onValueChange('123456')); + act(() => ready().onSubmit()); + act(() => ready().onShowMethods()); + await act(() => preparation.resolve()); + expect(ready().step).toBe('method-picker'); + expect(model.attempt).not.toHaveBeenCalled(); + act(() => ready().onBack()); + expect(ready().value).toBe('123456'); + expect(model.prepare).toHaveBeenCalledTimes(1); + }); + + it('ignores an old failure after selecting another method', async () => { + const { preparation, ready } = await preparing(); + act(() => ready().onShowMethods()); + act(() => ready().onSelectMethod(password.id)); + await act(() => preparation.reject(clerkApiError('preparation_failed', 'Old failure'))); + expect(ready().step).toBe('password'); + expect(ready().errorMessage).toBeUndefined(); + }); + + it('ignores a cancelled preparation after starting another run', async () => { + const { model, preparation, ready, rerender } = await preparing(); + act(() => ready().onShowMethods()); + rerender({ status: 'inactive' }); + rerender(model); + await waitFor(() => expect(model.prepare).toHaveBeenCalledTimes(2)); + await act(() => preparation.reject(clerkApiError('preparation_failed', 'Old failure'))); + expect(ready().step).toBe('otp'); + expect(ready().errorMessage).toBeUndefined(); + expect(ready().canResend).toBe(false); + }); +}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 8fb2a015672..211c1894a7c 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -665,6 +665,30 @@ describe('Reverification', () => { expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); }); + it('keeps a code delivery failure after returning from the method picker', async () => { + await renderReverification( + guardedAction(), + verificationSeed({ firstFactors: [emailFactor, passwordFactor] }, { environment: preferring('otp') }), + ); + const prepare = holdRequests('post', PREPARE_FIRST); + const user = await startVerification(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + await useAnotherMethod(user); + expect(await screen.findByRole('button', { name: 'Continue with your password' })).toBeVisible(); + + prepare.fail('preparation_failed', 'Could not send your code'); + await settle(); + await user.click(screen.getByRole('button', { name: 'Back' })); + + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + expect(await screen.findByText('Could not send your code')).toBeVisible(); + expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); + const retry = holdRequests('post', PREPARE_FIRST); + await user.click(screen.getByRole('button', { name: /Resend/ })); + await waitFor(() => expect(retry.requests).toHaveLength(1)); + retry.release(); + }); + it('resends a code once the cooldown has passed', async () => { const { fapi } = await renderReverification(guardedAction(), verificationSeed({ firstFactors: [emailFactor] })); vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 46665d6216a..05879714aa8 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -49,6 +49,7 @@ interface ReverificationContext { submitRequested: boolean; frozenActiveMethodId: string | undefined; overlayFrom: OverlayFrom; + preparationView: 'verifying' | 'methodPicker' | 'help'; supportEmail: string; deps: ReverificationDeps; } @@ -169,6 +170,20 @@ const factorEvents = { RESET: 'inactive', } satisfies NonNullable['on']>; +const methodPickerSelection = { + SELECT_METHOD: [ + { + target: 'methodPickerPreparing', + guard: (ctx, event) => { + const method = ctx.methods.find(candidate => candidate.id === event.id); + return Boolean(method && needsPrepare(method)); + }, + actions: assign((ctx, event) => selectMethod(ctx, event.id)), + }, + { target: 'verifying', actions: assign((ctx, event) => selectMethod(ctx, event.id)) }, + ], +} satisfies NonNullable['on']>; + export const reverificationMachine = createMachine({ id: 'reverification', initial: 'inactive', @@ -182,6 +197,7 @@ export const reverificationMachine = createMachine({ submitRequested: false, frozenActiveMethodId: undefined, overlayFrom: 'factor', + preparationView: 'verifying', supportEmail: '', deps: unseatedDeps, }, @@ -211,29 +227,50 @@ export const reverificationMachine = createMachine({ as verifying, but if the user submits, we queue that up until after prepare resolves */ preparing: { + entry: assign(() => ({ preparationView: 'verifying' as const })), on: { ...factorEvents, + ...methodPickerSelection, + SHOW_METHODS: { + ...factorEvents.SHOW_METHODS, + target: undefined, + actions: [factorEvents.SHOW_METHODS.actions, assign(() => ({ preparationView: 'methodPicker' as const }))], + }, + SHOW_HELP: { + actions: assign(ctx => ({ + preparationView: 'help' as const, + overlayFrom: ctx.preparationView === 'methodPicker' ? ('method-picker' as const) : ('factor' as const), + direction: 1 as const, + submitRequested: false, + })), + }, + BACK: { + actions: assign(ctx => ({ + preparationView: + ctx.preparationView === 'help' && ctx.overlayFrom === 'method-picker' + ? ('methodPicker' as const) + : ('verifying' as const), + direction: -1 as const, + })), + }, SUBMIT: { + guard: ctx => ctx.preparationView === 'verifying', actions: assign(() => ({ submitRequested: true })), }, }, invoke: fromPromise(prepareActive, { - onDone: [ - { - guard: (ctx: ReverificationContext) => ctx.submitRequested, - target: 'submitting' as const, - actions: assign(() => ({ submitRequested: false })), - }, - { target: 'verifying' as const }, - ], - onError: { - target: 'verifying', - actions: assign((_, event) => ({ + onDone: ({ context }) => ({ + target: context.submitRequested ? 'submitting' : context.preparationView, + context: { submitRequested: false }, + }), + onError: ({ context, event }) => ({ + target: context.preparationView, + context: { errors: toLocalizableErrors(event.error), submitRequested: false, ...unlockResend(), - })), - }, + }, + }), }), }, @@ -295,17 +332,7 @@ export const reverificationMachine = createMachine({ methodPicker: { on: { - SELECT_METHOD: [ - { - target: 'methodPickerPreparing', - guard: (ctx, event) => { - const method = ctx.methods.find(candidate => candidate.id === event.id); - return Boolean(method && needsPrepare(method)); - }, - actions: assign((ctx, event) => selectMethod(ctx, event.id)), - }, - { target: 'verifying', actions: assign((ctx, event) => selectMethod(ctx, event.id)) }, - ], + ...methodPickerSelection, SHOW_HELP: { target: 'help', actions: assign(() => ({ direction: 1 as const, overlayFrom: 'method-picker' as const })), @@ -483,7 +510,7 @@ function useReverificationState(model: ReverificationModel): ReverificationContr const { context } = snapshot; const activeMethod = context.activeMethod; - const state = snapshot.value; + const state = snapshot.value === 'preparing' ? context.preparationView : snapshot.value; if (state === 'unavailable') { return { status: 'error', reason: 'noFactors', onCancel }; From c763b5fb312aa6464d7f9641e3add12c86783a0d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 30 Oct 2026 23:25:57 -0600 Subject: [PATCH 2/5] refactor(mosaic): model reverification picker and help as an overlay stack Replace the methodPicker and help states with an overlay stack in context, so navigating away from a factor never leaves preparing or verifying. Move the preparation navigation tests into the feature suite. --- .changeset/fresh-otp-preparation.md | 3 - .../reverification-preparation.test.ts | 155 --------------- .../__tests__/reverification.feature.test.tsx | 178 ++++++++++++++++-- .../reverification.controller.ts | 140 +++++--------- 4 files changed, 211 insertions(+), 265 deletions(-) delete mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts diff --git a/.changeset/fresh-otp-preparation.md b/.changeset/fresh-otp-preparation.md index 785c106fb9b..a845151cc84 100644 --- a/.changeset/fresh-otp-preparation.md +++ b/.changeset/fresh-otp-preparation.md @@ -1,5 +1,2 @@ --- -"@clerk/mosaic": patch --- - -Preserve verification code delivery errors when returning from the method picker in profile reverification, allowing an immediate resend after a failed delivery. diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts b/packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts deleted file mode 100644 index cca5b1ab564..00000000000 --- a/packages/mosaic/src/features/reverification/__tests__/reverification-preparation.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { deferred } from '../../../__tests__/async'; -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { useReverificationController } from '../reverification.controller'; -import type { ReverificationActiveModel, ReverificationModel } from '../reverification.model'; -import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; - -const email: ReverificationMethod = { - id: 'email', - stage: 'first', - strategy: 'email_code', - emailAddressId: 'idn_email', - identifier: 'alice@example.com', -}; -const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; -const challenge: ReverificationResult = { - status: 'needs_first_factor', - methods: [email, password], - startingMethod: email, -}; - -async function preparing() { - const preparation = deferred(); - const model: ReverificationActiveModel = { - status: 'active', - supportEmail: '', - start: vi.fn().mockResolvedValue(challenge), - prepare: vi.fn().mockReturnValueOnce(preparation.promise).mockResolvedValue(undefined), - attempt: vi.fn().mockResolvedValue({ ...challenge, status: 'complete' }), - finish: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn(), - }; - const hook = renderHook((props: ReverificationModel) => useReverificationController(props), { - initialProps: model, - }); - const ready = () => { - const controller = hook.result.current; - if (controller.status !== 'ready') { - throw new Error(`Expected ready, received ${controller.status}`); - } - return controller; - }; - await waitFor(() => expect(model.prepare).toHaveBeenCalledTimes(1)); - return { ...hook, model, preparation, ready }; -} - -describe('OTP preparation while navigating', () => { - it.each(['method-picker', 'help'] as const)( - 'retains failure while viewing %s and allows an immediate retry', - async step => { - const { model, preparation, ready } = await preparing(); - act(() => ready().onShowMethods()); - if (step === 'help') { - act(() => ready().onShowHelp()); - } - await act(() => preparation.reject(clerkApiError('preparation_failed', 'Could not send your code'))); - expect(ready().step).toBe(step); - if (step === 'help') { - act(() => ready().onBack()); - expect(ready().step).toBe('method-picker'); - } - act(() => ready().onBack()); - expect(ready().step).toBe('otp'); - expect(ready().errorMessage).toBe('Could not send your code'); - expect(ready().canResend).toBe(true); - await act(() => ready().onResend()); - expect(model.prepare).toHaveBeenCalledTimes(2); - expect(ready().errorMessage).toBeUndefined(); - expect(ready().canResend).toBe(false); - }, - ); - - it('keeps a successful send and its cooldown when returning from the picker', async () => { - const { model, preparation, ready } = await preparing(); - act(() => ready().onShowMethods()); - await act(() => preparation.resolve()); - expect(ready().step).toBe('method-picker'); - act(() => ready().onBack()); - expect(ready().step).toBe('otp'); - expect(ready().errorMessage).toBeUndefined(); - expect(ready().canResend).toBe(false); - act(() => ready().onResend()); - expect(model.prepare).toHaveBeenCalledTimes(1); - }); - - it.each(['success', 'failure'] as const)( - 'holds a submit after Back until preparation settles with %s', - async outcome => { - const { model, preparation, ready } = await preparing(); - act(() => ready().onShowMethods()); - act(() => ready().onBack()); - act(() => ready().onValueChange('123456')); - act(() => ready().onSubmit()); - expect(model.attempt).not.toHaveBeenCalled(); - expect(model.prepare).toHaveBeenCalledTimes(1); - if (outcome === 'failure') { - await act(() => preparation.reject(clerkApiError('preparation_failed', 'Could not send your code'))); - expect(model.attempt).not.toHaveBeenCalled(); - expect(ready().errorMessage).toBe('Could not send your code'); - expect(ready().canResend).toBe(true); - } else { - await act(() => preparation.resolve()); - expect(model.attempt).toHaveBeenCalledExactlyOnceWith(email, '123456'); - } - }, - ); - - it('returns from help to the pending factor without sending another code', async () => { - const { model, preparation, ready } = await preparing(); - act(() => ready().onShowHelp()); - expect(ready().step).toBe('help'); - act(() => ready().onBack()); - expect(ready().step).toBe('otp'); - await act(() => preparation.reject(clerkApiError('preparation_failed', 'Could not send your code'))); - expect(ready().errorMessage).toBe('Could not send your code'); - expect(ready().canResend).toBe(true); - expect(model.prepare).toHaveBeenCalledTimes(1); - }); - - it('discards a queued submit when opening the picker', async () => { - const { model, preparation, ready } = await preparing(); - act(() => ready().onValueChange('123456')); - act(() => ready().onSubmit()); - act(() => ready().onShowMethods()); - await act(() => preparation.resolve()); - expect(ready().step).toBe('method-picker'); - expect(model.attempt).not.toHaveBeenCalled(); - act(() => ready().onBack()); - expect(ready().value).toBe('123456'); - expect(model.prepare).toHaveBeenCalledTimes(1); - }); - - it('ignores an old failure after selecting another method', async () => { - const { preparation, ready } = await preparing(); - act(() => ready().onShowMethods()); - act(() => ready().onSelectMethod(password.id)); - await act(() => preparation.reject(clerkApiError('preparation_failed', 'Old failure'))); - expect(ready().step).toBe('password'); - expect(ready().errorMessage).toBeUndefined(); - }); - - it('ignores a cancelled preparation after starting another run', async () => { - const { model, preparation, ready, rerender } = await preparing(); - act(() => ready().onShowMethods()); - rerender({ status: 'inactive' }); - rerender(model); - await waitFor(() => expect(model.prepare).toHaveBeenCalledTimes(2)); - await act(() => preparation.reject(clerkApiError('preparation_failed', 'Old failure'))); - expect(ready().step).toBe('otp'); - expect(ready().errorMessage).toBeUndefined(); - expect(ready().canResend).toBe(false); - }); -}); diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx index 211c1894a7c..c010c91de98 100644 --- a/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.feature.test.tsx @@ -665,28 +665,168 @@ describe('Reverification', () => { expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); }); - it('keeps a code delivery failure after returning from the method picker', async () => { - await renderReverification( - guardedAction(), - verificationSeed({ firstFactors: [emailFactor, passwordFactor] }, { environment: preferring('otp') }), + describe('when the user leaves the step while the code is being sent', () => { + async function startSendingCode( + firstFactors: SignInFirstFactorJSON[] = [emailFactor, passwordFactor], + action = guardedAction(), + ) { + await renderReverification(action, verificationSeed({ firstFactors }, { environment: preferring('otp') })); + const prepare = holdRequests('post', PREPARE_FIRST); + const user = await startVerification(); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + return { prepare, user }; + } + + const back = (user: User) => user.click(screen.getByRole('button', { name: 'Back' })); + + it.each(['picker', 'help'] as const)( + 'shows a failure that arrives on the %s step once the user returns', + async step => { + const { prepare, user } = await startSendingCode(); + await useAnotherMethod(user); + if (step === 'help') { + await user.click(await screen.findByRole('button', { name: 'Get help' })); + expect(await screen.findByText(/If you have trouble verifying your account/)).toBeVisible(); + } + + prepare.fail('preparation_failed', 'Could not send your code'); + await settle(); + if (step === 'help') { + expect(screen.getByText(/If you have trouble verifying your account/)).toBeVisible(); + await back(user); + } + expect(await screen.findByRole('button', { name: 'Continue with your password' })).toBeVisible(); + await back(user); + + // The step shows the failure and the user can resend immediately + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + expect(await screen.findByText('Could not send your code')).toBeVisible(); + expect(prepare.requests).toHaveLength(1); + const retry = holdRequests('post', PREPARE_FIRST); + await user.click(screen.getByRole('button', { name: /Resend/ })); + await waitFor(() => expect(retry.requests).toHaveLength(1)); + expect(screen.queryByText('Could not send your code')).toBeNull(); + expect(screen.getByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled(); + retry.release(); + }, ); - const prepare = holdRequests('post', PREPARE_FIRST); - const user = await startVerification(); - await waitFor(() => expect(prepare.requests).toHaveLength(1)); - await useAnotherMethod(user); - expect(await screen.findByRole('button', { name: 'Continue with your password' })).toBeVisible(); - prepare.fail('preparation_failed', 'Could not send your code'); - await settle(); - await user.click(screen.getByRole('button', { name: 'Back' })); + it('keeps the cooldown of a code sent while the picker is open', async () => { + const { prepare, user } = await startSendingCode(); + await useAnotherMethod(user); - expect(await screen.findByText(STEP.emailCode)).toBeVisible(); - expect(await screen.findByText('Could not send your code')).toBeVisible(); - expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); - const retry = holdRequests('post', PREPARE_FIRST); - await user.click(screen.getByRole('button', { name: /Resend/ })); - await waitFor(() => expect(retry.requests).toHaveLength(1)); - retry.release(); + prepare.release(); + await settle(); + expect(screen.getByRole('button', { name: 'Continue with your password' })).toBeVisible(); + await back(user); + + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + expect(screen.getByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled(); + expect(prepare.requests).toHaveLength(1); + }); + + it.each(['sent', 'not sent'] as const)( + 'holds a submit made after returning from the picker until the code is %s', + async result => { + const { prepare, user } = await startSendingCode(); + const attempt = holdRequests('post', ATTEMPT_FIRST); + await useAnotherMethod(user); + await back(user); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + + await pasteCode(user, CODE); + await settle(); + expect(attempt.requests).toHaveLength(0); + + if (result === 'sent') { + prepare.release(); + await waitFor(() => expect(attempt.requests).toHaveLength(1)); + attempt.release(); + await untilResolved(); + } else { + prepare.fail('preparation_failed', 'Could not send your code'); + expect(await screen.findByText('Could not send your code')).toBeVisible(); + expect(attempt.requests).toHaveLength(0); + expect(screen.getByRole('button', { name: /Resend/ })).toBeEnabled(); + attempt.release(); + } + expect(prepare.requests).toHaveLength(1); + }, + ); + + it('drops a held submit when the user opens the picker', async () => { + const { prepare, user } = await startSendingCode(); + const attempt = holdRequests('post', ATTEMPT_FIRST); + await pasteCode(user, CODE); + await useAnotherMethod(user); + + prepare.release(); + await settle(); + expect(screen.getByRole('button', { name: 'Continue with your password' })).toBeVisible(); + expect(attempt.requests).toHaveLength(0); + + // The typed code is still there on return + await back(user); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + expect(slotValues()).toEqual(CODE.split('')); + expect(attempt.requests).toHaveLength(0); + expect(prepare.requests).toHaveLength(1); + attempt.release(); + }); + + it('ignores a late failure after the user picks the password', async () => { + const { prepare, user } = await startSendingCode(); + await useAnotherMethod(user); + await user.click(await screen.findByRole('button', { name: 'Continue with your password' })); + expect(await screen.findByText(STEP.password)).toBeVisible(); + + prepare.fail('preparation_failed', 'Could not send your code'); + await settle(); + expect(screen.getByText(STEP.password)).toBeVisible(); + expect(screen.queryByText('Could not send your code')).toBeNull(); + }); + + it('ignores a late failure after the user picks another email address', async () => { + const { prepare, user } = await startSendingCode([emailFactor, workEmailFactor]); + await useAnotherMethod(user); + const second = holdRequests('post', PREPARE_FIRST); + await user.click(await screen.findByRole('button', { name: 'Email code to alice@work.example' })); + await waitFor(() => expect(second.requests).toHaveLength(1)); + + prepare.fail('preparation_failed', 'Could not send your code'); + await settle(); + expect(screen.getByRole('button', { name: 'Email code to alice@work.example' })).toBeVisible(); + expect(screen.queryByText('Could not send your code')).toBeNull(); + expect(prepare.requests).toHaveLength(1); + expect(second.requests).toHaveLength(1); + second.release(); + }); + + it('ignores a late failure after the challenge is dismissed from the picker and run again', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(reverificationError()) + .mockResolvedValueOnce(reverificationError()); + const { prepare, user } = await startSendingCode(undefined, action); + await useAnotherMethod(user); + await dismiss(user); + await waitFor(() => expect(outcome()).toBe('rejected: reverification_cancelled')); + await untilClosed(); + + const next = holdRequests('post', PREPARE_FIRST); + await user.click(screen.getByRole('button', { name: 'Run action' })); + expect(await screen.findByText(STEP.emailCode)).toBeVisible(); + await waitFor(() => expect(next.requests).toHaveLength(1)); + + prepare.fail('preparation_failed', 'Could not send your code'); + await settle(); + expect(screen.getByText(STEP.emailCode)).toBeVisible(); + expect(screen.queryByText('Could not send your code')).toBeNull(); + expect(screen.getByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled(); + expect(prepare.requests).toHaveLength(1); + next.release(); + }); }); it('resends a code once the cooldown has passed', async () => { diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 05879714aa8..d50f5d013a6 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -35,7 +35,7 @@ export type ReverificationController = ReverificationControllerState & { visible: boolean; }; -type OverlayFrom = 'factor' | 'method-picker'; +type Overlay = Extract; export type ReverificationDeps = Pick; @@ -48,8 +48,7 @@ interface ReverificationContext { resendAvailableAt: number | undefined; submitRequested: boolean; frozenActiveMethodId: string | undefined; - overlayFrom: OverlayFrom; - preparationView: 'verifying' | 'methodPicker' | 'help'; + overlay: readonly Overlay[]; supportEmail: string; deps: ReverificationDeps; } @@ -145,43 +144,52 @@ const afterResult = [ { target: 'verifying' as const, actions: applyResult }, ]; +function topOverlay(ctx: ReverificationContext): Overlay | undefined { + return ctx.overlay[ctx.overlay.length - 1]; +} + const factorEvents = { TYPE: { actions: assign((_, event) => ({ inputValue: event.value, errors: [] })), }, SHOW_METHODS: { - target: 'methodPicker', - guard: ctx => ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, + guard: ctx => + ctx.overlay.length === 0 && ctx.methods.filter(method => method.id !== ctx.activeMethod?.id).length > 0, actions: assign(ctx => ({ + overlay: ['method-picker' as const], direction: 1 as const, - overlayFrom: 'factor' as const, submitRequested: false, frozenActiveMethodId: ctx.activeMethod?.id, })), }, SHOW_HELP: { - target: 'help', - actions: assign(() => ({ + guard: ctx => topOverlay(ctx) !== 'help', + actions: assign(ctx => ({ + overlay: [...ctx.overlay, 'help' as const], direction: 1 as const, - overlayFrom: 'factor' as const, submitRequested: false, })), }, - RESET: 'inactive', -} satisfies NonNullable['on']>; - -const methodPickerSelection = { + BACK: { + guard: ctx => ctx.overlay.length > 0, + actions: assign(ctx => ({ overlay: ctx.overlay.slice(0, -1), direction: -1 as const })), + }, SELECT_METHOD: [ { target: 'methodPickerPreparing', guard: (ctx, event) => { const method = ctx.methods.find(candidate => candidate.id === event.id); - return Boolean(method && needsPrepare(method)); + return topOverlay(ctx) === 'method-picker' && Boolean(method && needsPrepare(method)); }, actions: assign((ctx, event) => selectMethod(ctx, event.id)), }, - { target: 'verifying', actions: assign((ctx, event) => selectMethod(ctx, event.id)) }, + { + target: 'verifying', + guard: ctx => topOverlay(ctx) === 'method-picker', + actions: assign((ctx, event) => ({ ...selectMethod(ctx, event.id), overlay: [] })), + }, ], + RESET: 'inactive', } satisfies NonNullable['on']>; export const reverificationMachine = createMachine({ @@ -196,8 +204,7 @@ export const reverificationMachine = createMachine({ resendAvailableAt: undefined, submitRequested: false, frozenActiveMethodId: undefined, - overlayFrom: 'factor', - preparationView: 'verifying', + overlay: [], supportEmail: '', deps: unseatedDeps, }, @@ -207,6 +214,7 @@ export const reverificationMachine = createMachine({ inputValue: '', errors: [], submitRequested: false, + overlay: [], ...unlockResend(), })), on: { START: 'starting' }, @@ -227,50 +235,30 @@ export const reverificationMachine = createMachine({ as verifying, but if the user submits, we queue that up until after prepare resolves */ preparing: { - entry: assign(() => ({ preparationView: 'verifying' as const })), on: { ...factorEvents, - ...methodPickerSelection, - SHOW_METHODS: { - ...factorEvents.SHOW_METHODS, - target: undefined, - actions: [factorEvents.SHOW_METHODS.actions, assign(() => ({ preparationView: 'methodPicker' as const }))], - }, - SHOW_HELP: { - actions: assign(ctx => ({ - preparationView: 'help' as const, - overlayFrom: ctx.preparationView === 'methodPicker' ? ('method-picker' as const) : ('factor' as const), - direction: 1 as const, - submitRequested: false, - })), - }, - BACK: { - actions: assign(ctx => ({ - preparationView: - ctx.preparationView === 'help' && ctx.overlayFrom === 'method-picker' - ? ('methodPicker' as const) - : ('verifying' as const), - direction: -1 as const, - })), - }, SUBMIT: { - guard: ctx => ctx.preparationView === 'verifying', + guard: ctx => ctx.overlay.length === 0, actions: assign(() => ({ submitRequested: true })), }, }, invoke: fromPromise(prepareActive, { - onDone: ({ context }) => ({ - target: context.submitRequested ? 'submitting' : context.preparationView, - context: { submitRequested: false }, - }), - onError: ({ context, event }) => ({ - target: context.preparationView, - context: { + onDone: [ + { + guard: (ctx: ReverificationContext) => ctx.submitRequested, + target: 'submitting' as const, + actions: assign(() => ({ submitRequested: false })), + }, + { target: 'verifying' as const }, + ], + onError: { + target: 'verifying', + actions: assign((_, event) => ({ errors: toLocalizableErrors(event.error), submitRequested: false, ...unlockResend(), - }, - }), + })), + }, }), }, @@ -284,10 +272,10 @@ export const reverificationMachine = createMachine({ entry: assign(() => lockResend()), on: { RESET: 'inactive' }, invoke: fromPromise(prepareActive, { - onDone: 'verifying', + onDone: { target: 'verifying', actions: assign(() => ({ overlay: [] })) }, onError: { target: 'verifying', - actions: assign((_, event) => ({ errors: toLocalizableErrors(event.error), ...unlockResend() })), + actions: assign((_, event) => ({ errors: toLocalizableErrors(event.error), overlay: [], ...unlockResend() })), }, }), }, @@ -296,10 +284,11 @@ export const reverificationMachine = createMachine({ always: [{ guard: ctx => ctx.activeMethod === null, target: 'unavailable' }], on: { ...factorEvents, - SUBMIT: 'submitting', + SUBMIT: { target: 'submitting', guard: ctx => ctx.overlay.length === 0 }, RESEND: { target: 'preparing', guard: ctx => + ctx.overlay.length === 0 && Boolean(ctx.activeMethod && needsPrepare(ctx.activeMethod)) && (ctx.resendAvailableAt === undefined || Date.now() >= ctx.resendAvailableAt), actions: assign(() => ({ @@ -330,32 +319,6 @@ export const reverificationMachine = createMachine({ }), }, - methodPicker: { - on: { - ...methodPickerSelection, - SHOW_HELP: { - target: 'help', - actions: assign(() => ({ direction: 1 as const, overlayFrom: 'method-picker' as const })), - }, - BACK: { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, - RESET: 'inactive', - }, - }, - - help: { - on: { - BACK: [ - { - target: 'methodPicker', - guard: ctx => ctx.overlayFrom === 'method-picker', - actions: assign(() => ({ direction: -1 as const })), - }, - { target: 'verifying', actions: assign(() => ({ direction: -1 as const })) }, - ], - RESET: 'inactive', - }, - }, - unavailable: { on: { RESET: 'inactive' }, }, @@ -404,12 +367,13 @@ function factorStep(method: ReverificationMethod): ReverificationStep { return 'otp'; } -function viewStep(value: string, method: ReverificationMethod | null): ReverificationStep | undefined { - if (value === 'methodPicker' || value === 'methodPickerPreparing') { - return 'method-picker'; - } - if (value === 'help') { - return 'help'; +function viewStep( + value: string, + overlay: Overlay | undefined, + method: ReverificationMethod | null, +): ReverificationStep | undefined { + if (overlay) { + return overlay; } if ( value === 'verifying' || @@ -510,7 +474,7 @@ function useReverificationState(model: ReverificationModel): ReverificationContr const { context } = snapshot; const activeMethod = context.activeMethod; - const state = snapshot.value === 'preparing' ? context.preparationView : snapshot.value; + const state = snapshot.value; if (state === 'unavailable') { return { status: 'error', reason: 'noFactors', onCancel }; @@ -520,7 +484,7 @@ function useReverificationState(model: ReverificationModel): ReverificationContr return { status: 'error', reason: 'generic', onCancel }; } - const step = viewStep(state, activeMethod); + const step = viewStep(state, topOverlay(context), activeMethod); if (!step) { return { status: 'loading', onCancel }; From 3c235e00bc4e865534545e866ecd2bfac91296b1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 31 Oct 2026 00:28:44 -0600 Subject: [PATCH 3/5] chore(ci): diagnose Mosaic browser initialization timeout --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8674509bf2d..14588a6f803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -426,6 +426,10 @@ jobs: run: pnpm turbo build $TURBO_ARGS --filter=@clerk/mosaic... - name: Run feature tests + env: + VITEST_BROWSER_DEBUG: 'true' + VITEST_PW_DEBUG: '1' + DEBUG: 'vitest:browser:gc' run: pnpm --filter @clerk/mosaic test:feature - name: Run browser tests From 8c6328ca5fbf2af5724c33d7d50c31a4ef56ef76 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 31 Oct 2026 00:56:05 -0600 Subject: [PATCH 4/5] test(mosaic): clear authentication cookies when test iframes unload --- .github/workflows/ci.yml | 4 ---- .../feature/fake-fapi-isolation.feature.test.tsx | 13 +++++++++++++ packages/mosaic/vitest.setup.browser.mts | 12 +++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14588a6f803..8674509bf2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -426,10 +426,6 @@ jobs: run: pnpm turbo build $TURBO_ARGS --filter=@clerk/mosaic... - name: Run feature tests - env: - VITEST_BROWSER_DEBUG: 'true' - VITEST_PW_DEBUG: '1' - DEBUG: 'vitest:browser:gc' run: pnpm --filter @clerk/mosaic test:feature - name: Run browser tests diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx b/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx index 9ed44df552f..f73dd43ba0c 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx @@ -44,4 +44,17 @@ describe('fake FAPI isolation between tests', () => { expect(await clerk.session?.getToken()).toEqual(expect.any(String)); expect(document.cookie).not.toMatch(/(?:^|; )__session/); }); + + it('clears authentication cookies when the test iframe leaves the page', async () => { + const user = fapiUser({ id: 'user_1' }); + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(
, { standardBrowser: true }); + window.dispatchEvent(new Event('focus')); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(document.cookie).toContain('__session='); + + window.dispatchEvent(new PageTransitionEvent('pagehide')); + + expect(document.cookie).toBe(''); + }); }); diff --git a/packages/mosaic/vitest.setup.browser.mts b/packages/mosaic/vitest.setup.browser.mts index 9688e433d04..ae98521db28 100644 --- a/packages/mosaic/vitest.setup.browser.mts +++ b/packages/mosaic/vitest.setup.browser.mts @@ -64,15 +64,21 @@ beforeEach(() => { window.focus(); }); +function clearCookies() { + for (const cookie of document.cookie.split('; ').filter(Boolean)) { + document.cookie = `${cookie.split('=')[0]}=; max-age=0; path=/`; + } +} + +window.addEventListener('pagehide', clearCookies); + afterEach(() => { cleanup(); __resetClerkQueryClientForTest(); for (const channel of [...openChannels]) { channel.close(); } - for (const cookie of document.cookie.split('; ').filter(Boolean)) { - document.cookie = `${cookie.split('=')[0]}=; max-age=0; path=/`; - } + clearCookies(); localStorage.clear(); sessionStorage.clear(); worker.resetHandlers(); From a691aef8284db1d8325e770b200acf225f1cbd07 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sat, 31 Oct 2026 10:31:35 -0600 Subject: [PATCH 5/5] docs(mosaic): add OTP navigation release note --- .changeset/fresh-otp-preparation.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/fresh-otp-preparation.md b/.changeset/fresh-otp-preparation.md index a845151cc84..5dcd7745562 100644 --- a/.changeset/fresh-otp-preparation.md +++ b/.changeset/fresh-otp-preparation.md @@ -1,2 +1,5 @@ --- +'@clerk/mosaic': patch --- + +Preserve verification code delivery errors and resend cooldowns when navigating to other methods or help and back in UserProfile.