diff --git a/.changeset/mosaic-confirm-pending.md b/.changeset/mosaic-confirm-pending.md new file mode 100644 index 00000000000..ffcd43bd761 --- /dev/null +++ b/.changeset/mosaic-confirm-pending.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Confirm dialogs now stay busy until their action finishes, even after you cancel a reverification prompt, and they can't be reopened or confirmed a second time while the request is in flight. diff --git a/packages/mosaic/src/blocks/confirm/__tests__/confirm.controller.test.ts b/packages/mosaic/src/blocks/confirm/__tests__/confirm.controller.test.ts index e6ec9447bfa..7fc0e8d8704 100644 --- a/packages/mosaic/src/blocks/confirm/__tests__/confirm.controller.test.ts +++ b/packages/mosaic/src/blocks/confirm/__tests__/confirm.controller.test.ts @@ -38,20 +38,43 @@ describe('confirmMachine', () => { expect(actor.getSnapshot().status).toBe('active'); }); - it('abandons the run when cancelled while pending, ignoring how it settles', async () => { + it('closes when cancelled while pending, and stays pending until the run settles', async () => { const run = deferred(); const actor = start(); actor.send({ type: 'CONFIRM', run: () => run.promise }); actor.send({ type: 'CANCEL' }); - expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().value).toBe('pending'); + expect(actor.getSnapshot().context.open).toBe(false); + + run.resolve(); + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); + }); + + it('ends idle without a reason when a cancelled run fails', async () => { + const run = deferred(); + const actor = start(); + actor.send({ type: 'CONFIRM', run: () => run.promise }); + actor.send({ type: 'CANCEL' }); run.reject(blocked); - await run.promise.catch(() => undefined); - expect(actor.getSnapshot().value).toBe('idle'); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); expect(actor.getSnapshot().context.error).toBeUndefined(); }); + it('stays closed when reopened while a cancelled run is still pending', () => { + const run = deferred(); + const actor = start(); + actor.send({ type: 'CONFIRM', run: () => run.promise }); + actor.send({ type: 'CANCEL' }); + + actor.send({ type: 'OPEN' }); + + expect(actor.getSnapshot().value).toBe('pending'); + expect(actor.getSnapshot().context.open).toBe(false); + }); + it('lands back on confirming with the reason when the action fails', async () => { const actor = start(); actor.send({ type: 'CONFIRM', run: () => Promise.reject(blocked) }); @@ -121,4 +144,30 @@ describe('useConfirmController', () => { }); await waitFor(() => expect(result.current.isOpen).toBe(false)); }); + + it('closes on a cancelled reverification but reports pending until the action settles', async () => { + const pending = deferred(); + const onCancel = vi.fn(); + const { result } = renderHook(() => + useConfirmController({ + onConfirm: () => pending.promise, + reverification: { status: 'loading', onCancel, visible: false }, + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.confirm()); + + act(() => result.current.onOpenChange(false)); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(result.current.isOpen).toBe(false); + expect(result.current.isPending).toBe(true); + + await act(async () => { + pending.reject(cancelled); + await pending.promise.catch(() => undefined); + }); + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(result.current.isOpen).toBe(false); + }); }); diff --git a/packages/mosaic/src/blocks/confirm/confirm.controller.ts b/packages/mosaic/src/blocks/confirm/confirm.controller.ts index 6924f103b5b..c0df19156fc 100644 --- a/packages/mosaic/src/blocks/confirm/confirm.controller.ts +++ b/packages/mosaic/src/blocks/confirm/confirm.controller.ts @@ -14,6 +14,7 @@ import { toLocalizableError } from '../../utils/errors'; export interface ConfirmContext { run: () => Promise; error: ErrorDescription | undefined; + open: boolean; } export type ConfirmEvent = { type: 'OPEN' } | { type: 'CONFIRM'; run: () => Promise } | { type: 'CANCEL' }; @@ -30,29 +31,32 @@ export const confirmMachine = createMachine({ context: { run: notSeated, error: undefined, + open: false, }, states: { idle: { on: { - OPEN: { target: 'confirming', actions: assign(() => ({ error: undefined })) }, + OPEN: { target: 'confirming', actions: assign(() => ({ error: undefined, open: true })) }, }, }, confirming: { on: { CONFIRM: { target: 'pending', actions: assign((_, event) => ({ run: event.run, error: undefined })) }, - CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined, open: false })) }, }, }, pending: { on: { - CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + CANCEL: { actions: assign(() => ({ open: false })) }, }, invoke: fromPromise(context => context.run(), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + onDone: { target: 'idle', actions: assign(() => ({ error: undefined, open: false })) }, onError: [ { - guard: (_: ConfirmContext, event: ErrorInvokeEvent) => isReverificationCancelledError(event.error), + guard: (context: ConfirmContext, event: ErrorInvokeEvent) => + !context.open || isReverificationCancelledError(event.error), target: 'idle', + actions: assign(() => ({ open: false })), }, { target: 'confirming', @@ -117,7 +121,7 @@ export function useConfirmController({ return { handle, - isOpen: snapshot.value !== 'idle', + isOpen: snapshot.value !== 'idle' && snapshot.context.open, isPending, canClose, errorMessage: error ? errorText(error, errorFallback) : undefined,