Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-destructive-reverification-statecharts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
179 changes: 77 additions & 102 deletions packages/mosaic/src/blocks/destructive/destructive.controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { ClerkRuntimeError } from '@clerk/shared/error';
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import type { ReverificationController } from '../../features/reverification';
import { reverificationMachine } from '../../features/reverification/reverification.machine';
import { deferred } from '../../machines/__tests__/test-utils';
import { useDestructiveController } from './destructive.controller';
import { type DestructiveReverification, useDestructiveController } from './destructive.controller';

const idleReverification = { status: 'idle' } as ReverificationController;
const needsReverification = () =>
new ClerkAPIResponseError('reverify', {
data: [{ code: 'session_reverification_required', message: 'Reverify', long_message: 'Reverify' }],
status: 403,
});

function reverification(sessionId: string | null | undefined = 'sess_1'): DestructiveReverification {
return {
sessionId,
actors: {
reverification: reverificationMachine,
startVerification: () => new Promise(() => {}),
},
};
}

describe('useDestructiveController', () => {
it('starts closed and opens from the opener or from onOpenChange', () => {
Expand All @@ -28,9 +42,7 @@ describe('useDestructiveController', () => {
it('ignores the opener once the dialog is already open', () => {
const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() }));
act(() => result.current.onOpenChange(true));
act(() => {
void result.current.onDelete();
});
act(() => result.current.onDelete());

act(() => result.current.openDestructiveDialog());

Expand All @@ -43,9 +55,7 @@ describe('useDestructiveController', () => {
const { result } = renderHook(() => useDestructiveController({ onDelete }));
act(() => result.current.onOpenChange(true));

act(() => {
void result.current.onDelete();
});
act(() => result.current.onDelete());
expect(onDelete).toHaveBeenCalledOnce();
expect(result.current.open).toBe(true);
expect(result.current.isDeleting).toBe(true);
Expand All @@ -66,146 +76,111 @@ describe('useDestructiveController', () => {
const { result } = renderHook(() => useDestructiveController({ onDelete }));
act(() => result.current.onOpenChange(true));

await act(async () => {
await result.current.onDelete();
});
act(() => result.current.onDelete());
await waitFor(() => expect(result.current.errorMessage).toBe('Something went wrong'));
expect(result.current.open).toBe(true);
expect(result.current.isDeleting).toBe(false);
expect(result.current.errorMessage).toBe('Something went wrong');

await act(async () => {
await result.current.onDelete();
});
act(() => result.current.onDelete());
await waitFor(() => expect(result.current.open).toBe(false));
expect(onDelete).toHaveBeenCalledTimes(2);
});

it('closes without a message when reverification is cancelled', async () => {
const onDelete = vi.fn(() =>
Promise.reject(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })),
);
const { result } = renderHook(() => useDestructiveController({ onDelete, reverification: idleReverification }));
it('ignores a close while the action is in flight', () => {
const { result } = renderHook(() => useDestructiveController({ onDelete: () => new Promise(() => {}) }));
act(() => result.current.onOpenChange(true));

await act(async () => {
await result.current.onDelete();
});

expect(result.current.open).toBe(false);
expect(result.current.errorMessage).toBeUndefined();
});

it('ignores a close while the action is in flight, unless reverification is active', async () => {
const pending = deferred<void>();
const { result } = renderHook(() =>
useDestructiveController({ onDelete: () => pending.promise, reverification: idleReverification }),
);
act(() => result.current.onOpenChange(true));
act(() => {
void result.current.onDelete();
});
act(() => result.current.onDelete());

act(() => result.current.onOpenChange(false));

expect(result.current.open).toBe(true);
expect(result.current.isDeleting).toBe(true);

await act(async () => {
pending.resolve();
await pending.promise;
});
});

it('lets an active reverification close the dialog while the action is still pending', () => {
it('moves to the verify step and exposes the reverification actor when reverification is required', async () => {
const { result } = renderHook(() =>
useDestructiveController({
onDelete: () => new Promise(() => {}),
reverification: { status: 'ready', onCancel: vi.fn() } as ReverificationController,
onDelete: () => Promise.reject(needsReverification()),
reverification: reverification(),
}),
);
act(() => result.current.onOpenChange(true));
act(() => {
void result.current.onDelete();
});
expect(result.current.step).toBe('confirm');

act(() => result.current.onOpenChange(false));
act(() => result.current.onDelete());

expect(result.current.open).toBe(false);
await waitFor(() => expect(result.current.step).toBe('verify'));
expect(result.current.verification).toBeDefined();
expect(result.current.isDeleting).toBe(false);
});

it('ignores a close while reverification is retrying the action', () => {
it('shows the error when reverification is required but not wired', async () => {
const { result } = renderHook(() =>
useDestructiveController({
onDelete: () => new Promise(() => {}),
reverification: { status: 'retrying' } as ReverificationController,
}),
useDestructiveController({ onDelete: () => Promise.reject(needsReverification()) }),
);
act(() => result.current.onOpenChange(true));
act(() => {
void result.current.onDelete();
});

act(() => result.current.onOpenChange(false));

expect(result.current.open).toBe(true);
expect(result.current.isDeleting).toBe(true);
});

it('is on the confirm step without reverification', () => {
const { result } = renderHook(() => useDestructiveController({ onDelete: () => Promise.resolve() }));
act(() => result.current.onDelete());

await waitFor(() => expect(result.current.errorMessage).toBe('Something went wrong'));
expect(result.current.step).toBe('confirm');
});

it.each([
['idle', 'confirm'],
['loading', 'confirm'],
['unavailable', 'verify'],
['ready', 'verify'],
['retrying', 'verify'],
] as const)('reverification status %s uses the %s step', (status, step) => {
it('stops reverification when the dialog closes', async () => {
const { result } = renderHook(() =>
useDestructiveController({
onDelete: () => Promise.resolve(),
reverification: { status } as ReverificationController,
onDelete: () => Promise.reject(needsReverification()),
reverification: reverification(),
}),
);
act(() => result.current.onOpenChange(true));
act(() => result.current.onDelete());
await waitFor(() => expect(result.current.verification).toBeDefined());
const child = result.current.verification;

expect(result.current.step).toBe(step);
act(() => result.current.onOpenChange(false));

expect(result.current.open).toBe(false);
expect(result.current.verification).toBeUndefined();
expect(child?.getSnapshot().status).toBe('stopped');
});

it('cancels reverification when the dialog closes', () => {
const onCancel = vi.fn();
const { result } = renderHook(() =>
useDestructiveController({
onDelete: () => new Promise(() => {}),
reverification: { status: 'ready', onCancel } as ReverificationController,
}),
it('closes when the session changes during reverification', async () => {
const { result, rerender } = renderHook(
({ sessionId }) =>
useDestructiveController({
onDelete: () => Promise.reject(needsReverification()),
reverification: reverification(sessionId),
}),
{ initialProps: { sessionId: 'sess_1' } },
);
act(() => result.current.onOpenChange(true));
act(() => {
void result.current.onDelete();
});
act(() => result.current.onDelete());
await waitFor(() => expect(result.current.step).toBe('verify'));

act(() => result.current.onOpenChange(false));
rerender({ sessionId: 'sess_2' });

expect(onCancel).toHaveBeenCalledOnce();
expect(result.current.open).toBe(false);
});

it('does not cancel reverification when a close is ignored', () => {
const { result } = renderHook(() =>
useDestructiveController({
onDelete: () => new Promise(() => {}),
reverification: { status: 'retrying' } as ReverificationController,
}),
it('closes when the session is signed out during reverification, but not when it briefly unloads', async () => {
const { result, rerender } = renderHook(
({ sessionId }: { sessionId: string | null | undefined }) =>
useDestructiveController({
onDelete: () => Promise.reject(needsReverification()),
reverification: reverification(sessionId),
}),
{ initialProps: { sessionId: 'sess_1' } },
);
act(() => result.current.onOpenChange(true));
act(() => {
void result.current.onDelete();
});
act(() => result.current.onDelete());
await waitFor(() => expect(result.current.step).toBe('verify'));

act(() => result.current.onOpenChange(false));
rerender({ sessionId: undefined });
rerender({ sessionId: 'sess_1' });
expect(result.current.step).toBe('verify');

expect(result.current.open).toBe(true);
rerender({ sessionId: null });
expect(result.current.open).toBe(false);
});
});
97 changes: 35 additions & 62 deletions packages/mosaic/src/blocks/destructive/destructive.controller.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,57 @@
import { isReverificationCancelledError } from '@clerk/shared/error';
import { useState } from 'react';
import { useEffect, useRef } from 'react';

import type { ReverificationController } from '../../features/reverification';
import type { AnyActor, ProvidedActors } from '../../machine/types';
import { useMachine } from '../../machine/useMachine';
import type { DestructiveControlledProps } from './destructive';
import { destructiveMachine } from './destructive.machine';

export type DestructiveController = Pick<
DestructiveControlledProps,
'open' | 'isDeleting' | 'errorMessage' | 'onOpenChange' | 'step'
> & {
onDelete: () => Promise<unknown>;
onDelete: () => void;
openDestructiveDialog: () => void;
verification: AnyActor | undefined;
};

type DestructiveState =
| { status: 'closed' }
| { status: 'open-needs-confirmation' }
| { status: 'open-pending' }
| { status: 'open-error'; errorMessage: string };
export interface DestructiveReverification {
actors: ProvidedActors;
sessionId: string | null | undefined;
}

/** Optional controller for use with Destructive block */
export function useDestructiveController({
onDelete,
reverification,
}: {
onDelete: () => Promise<unknown>;
reverification?: ReverificationController;
reverification?: DestructiveReverification;
}): DestructiveController {
const [destructiveState, setDestructiveState] = useState<DestructiveState>({ status: 'closed' });
const { status } = destructiveState;

const openDestructiveDialog = () => {
if (status === 'closed') {
setDestructiveState({ status: 'open-needs-confirmation' });
const [snapshot, send] = useMachine(destructiveMachine, {
context: { reverifiable: reverification !== undefined },
actors: { ...reverification?.actors, action: onDelete },
});

const sessionId = reverification?.sessionId;
const knownSessionId = useRef(sessionId);
useEffect(() => {
if (sessionId === undefined) {
return;
}
};

const isDeleting = status === 'open-pending';
const step =
reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm';
const previous = knownSessionId.current;
knownSessionId.current = sessionId;
if (previous !== undefined && previous !== sessionId) {
send({ type: 'SESSION_CHANGED' });
}
}, [sessionId, send]);

return {
open: status !== 'closed',
isDeleting,
errorMessage: status === 'open-error' ? destructiveState.errorMessage : undefined,
step,
openDestructiveDialog,
onDelete: async () => {
if (status === 'open-needs-confirmation' || status === 'open-error') {
setDestructiveState({ status: 'open-pending' });
try {
await onDelete();
// TODO: It's possible this might give a flash of the confirm page after
// reverification.
// While we do stay on the current page throughout the retry, the
// useReverificationWithState `.finally` runs first and sets phase
// to inactive. That resets the reverification state and this
// dialog could flash to the first screen. Unconfirmed.
setDestructiveState({ status: 'closed' });
} catch (error: unknown) {
if (isReverificationCancelledError(error)) {
setDestructiveState({ status: 'closed' });
return;
}
// TODO: Better error handling, localization
setDestructiveState({ status: 'open-error', errorMessage: 'Something went wrong' });
}
}
},
onOpenChange: nextIsOpen => {
const cancelReverification = reverification?.onCancel;
if (!nextIsOpen && isDeleting && !cancelReverification) {
return;
}

if (nextIsOpen) {
setDestructiveState({ status: 'open-needs-confirmation' });
} else {
cancelReverification?.();
setDestructiveState({ status: 'closed' });
}
},
open: snapshot.matches('open'),
isDeleting: snapshot.matches('open.running') || snapshot.matches('open.verifying.retrying'),
errorMessage: snapshot.matches('open.failed') ? snapshot.context.errorMessage : undefined,
step: snapshot.matches('open.verifying') ? 'verify' : 'confirm',
verification: snapshot.children.reverification,
openDestructiveDialog: () => send({ type: 'OPEN' }),
onDelete: () => send({ type: 'CONFIRM' }),
onOpenChange: nextIsOpen => send(nextIsOpen ? { type: 'OPEN' } : { type: 'CLOSE' }),
};
}
Loading
Loading