Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c614e57
Implement new shared confirm controller and block
Ephem Oct 12, 2026
245779f
Update docs and swingset
Ephem Oct 12, 2026
4ec6429
Move the contact sections' removal confirm and focus hand-off into th…
Ephem Oct 12, 2026
7634290
Drive both danger sections from useConfirmController
Ephem Oct 12, 2026
3049160
Move the passkeys, web3 wallets and connected accounts removal confir…
Ephem Oct 12, 2026
4d63342
Open the MFA removal confirm from the leaf controller instead of inve…
Ephem Oct 12, 2026
56edf6e
Run the active devices sign-out and details dialogs from confirm cont…
Ephem Oct 12, 2026
63c5484
Move the API keys and organization table confirms and focus hand-offs…
Ephem Oct 12, 2026
862c11f
Add an empty changeset for the consumer migration
Ephem Oct 12, 2026
1c9b252
Move verifyingId from ref into machines
Ephem Oct 13, 2026
06a565c
Move leftover logic from views into controllers
Ephem Oct 13, 2026
38b1021
Fix linting
Ephem Oct 13, 2026
1fe4a60
Reverify email and phone removals inside their confirmation dialogs
Ephem Oct 12, 2026
d4576b0
Reverify passkey, web3 wallet and connected account removals inside t…
Ephem Oct 12, 2026
1d19107
Reverify two-step verification method removals inside the confirmatio…
Ephem Oct 12, 2026
7e18531
Reverify device sign-out from both the confirmation and the details d…
Ephem Oct 12, 2026
4cc72b2
Document where the reverification flow runs and add the changeset
Ephem Oct 12, 2026
96262dd
Make the changeset empty
Ephem Oct 13, 2026
7902229
chore(mosaic): export isReverificationError
austincalvelage Oct 13, 2026
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-confirm-controller-consumers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-confirm-controller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-export-is-reverification-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-row-reverification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
6 changes: 3 additions & 3 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ Most problems a feature hits already have a shared answer. Find the row for your
| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) |
| A form: values, validation, submit, field errors | `useForm` (`src/components/form/`), with `validateAsync` for a server-side check |
| A model save whose Clerk errors should land on form fields | `save` in the model (`src/utils/errors.ts`), or reject with `FormSubmitError` |
| A confirm step before an action | `Confirmation` + `useConfirmationController` (`src/blocks/confirmation/`) |
| A delete that needs the user to type a phrase | `Destructive` + `useDestructiveController` (`src/blocks/destructive/`) |
| An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`), passed to `useDestructiveController` |
| A confirm step before an action | `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) |
| A delete that needs the user to type a phrase | `useConfirmController` (`src/blocks/confirm/`) with `Destructive` (`src/blocks/destructive/`) |
| An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`) in the wrapper, passed to `useConfirmController` |
| Showing any error to the user | One of the owners above. They call `toLocalizableError` and resolve copy with `useErrorText` |
| Moving focus after a list row is removed | `useListRemovalFocus` (`src/hooks/`) |
| A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) |
Expand Down
22 changes: 11 additions & 11 deletions packages/mosaic/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,17 +490,17 @@ export function UserButtonView({ open, onOpenChange, pendingKey, onSignOutAll, .

A **block** is a view fragment that owns one piece of state nothing outside it can
use, and takes the rest as props. `blocks/destructive` is the example: it holds the
half-typed confirmation phrase and compares it, while `open`, `isDeleting`, and
`errorMessage` come from the controller, because those are what decide whether the
dialog closes or explains itself. `blocks/confirmation` is the same shape for a
confirm step with no typed phrase.

Each block ships its own controller (`useDestructiveController`,
`useConfirmationController`) that owns the pending and error state, so a feature
passes it the effect and spreads the result onto the block rather than wiring that
state by hand. Each also has a handle form (`DestructiveHandle`, and its
`Confirmation` equivalent) for one dialog shared by several triggers, where the
clicked row arrives as a payload.
half-typed confirmation phrase and compares it, while open, pending and the error
come from its controller, because those are what decide whether the dialog closes
or explains itself. `blocks/confirmation` is the same shape for a confirm step
with no typed phrase.

Both blocks are driven by one controller, `useConfirmController` (`blocks/confirm/`),
created in the wrapper or controller layer and handed to the block as `controller`.
It owns the dialog's lifecycle and the reverification step, and it carries a
payload: a dialog shared by a list is opened from any row with `open(payload)`, the
copy is derived from that payload, and the action receives it back. A dialog that
sits next to its button takes the button as `trigger` instead.

### Testing a flow

Expand Down
107 changes: 90 additions & 17 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ A controller is not a machine wrapper. It is the layer that owns the
interaction, and it holds that state in whichever tool the interaction's
complexity calls for:

| The interaction… | Hold it in |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Is a boolean or a controlled value, no async, nothing else depends on it | `useState` |
| Has an async lifecycle, or two values that must change together | A machine, same file |
| Has a coordinated async core plus some UI-only flags beside it | Both — machine for the core |
| Is one async action from a button or a row, needing only pending and an error | `usePendingAction` |
| Is a form | `useForm` |
| Is a confirm step before an action | `useConfirmationController` / `useDestructiveController` with their block |
| The interaction… | Hold it in |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Is a boolean or a controlled value, no async, nothing else depends on it | `useState` |
| Has an async lifecycle, or two values that must change together | A machine, same file |
| Has a coordinated async core plus some UI-only flags beside it | Both — machine for the core |
| Is one async action from a button or a row, needing only pending and an error | `usePendingAction` |
| Is a form | `useForm` |
| Is a confirm step before an action | `useConfirmController` with `Confirmation` or `Destructive` |

Check `packages/mosaic/AGENTS.md` → "Reuse before you write" before holding any
of this by hand. If nothing there fits, ask the user before adding a new pattern.
Expand All @@ -56,17 +56,43 @@ focus. To hold pending through an OAuth redirect, await a delay inside the
action (`user-profile-connected-accounts-section.controller.ts`). It is not for:

- a form → `useForm`
- a confirm step → `Confirmation` / `Destructive` and their controllers
- a confirm step → `useConfirmController` with `Confirmation` / `Destructive`
- an action whose result drives other state → a machine
- a check that reruns as the user types → `useDebouncedAsync` (`hooks/`), or a
form field's `validateAsync`

`useConfirmationController` earns a machine: the confirm is async, and a failure
must land back on the confirming step with a reason. `useUserButtonController`
earns one because `open` and `pendingKey` constrain each other and dismissing
must not abandon an in-flight invoke. `useDestructiveController` is the middle
ground: its states never overlap, so one `useState` holding a status union is
enough.
`useConfirmController` earns a machine: the confirm is async, a failure must land
back on the confirming step with a reason, and a cancelled reverification must
close without one. `useUserButtonController` earns one because `open` and
`pendingKey` constrain each other and dismissing must not abandon an in-flight
invoke.

`useConfirmController({ onConfirm, reverification, errorFallback })` owns one
confirm dialog: open, pending, error, and the verify step a reverified action
swaps in. It is generic over a payload. Create it in the wrapper, pass it to the
view, and the view hands it to the block as `controller` and opens it from a row
with `open(payload)`; the copy props read the payload and `onConfirm` receives it.
A dialog next to its button has no payload and takes the button as `trigger`.

An action Clerk may interrupt for step-up verification goes through
`useReverificationFlow` first. It reads Clerk hooks, so the wrapper calls it next
to the model and hands the controller the wrapped action together with the
`reverification` it returns; the controller passes both on, and the block shows
the verify step inside the same dialog:

```ts
// wrapper
const [revoke, reverification] = useReverificationFlow((id: string) => model.revoke(id));
const controller = useInvitationsController({ ...model, revoke, reverification });

// controller
const removalFocus = useListRemovalFocus({ ids, onRemove: revoke, fallback });
const remove = useConfirmController<Invitation>({
onConfirm: invitation => removalFocus.remove(invitation.id),
reverification,
errorFallback: m.revokeError,
});
```

A single open boolean earns nothing. A two-state machine with one event is a
boolean spelled long. `useUserProfileEditUsernameController` holds `isOpen` in
Expand Down Expand Up @@ -201,6 +227,53 @@ When introducing lazy loading or Suspense, consider a boundary around dialog
content that leaves the trigger and parent available. Choose that boundary from
the feature's loading behavior; it does not require restructuring every dialog.

## Removing a row takes focus with it

A dialog returns focus to its trigger on close. When the action removes the row
that trigger sits on — signing a device out, removing an email — the trigger
unmounts and focus falls to `<body>`. Nothing catches it: the menu item that
opened the dialog unmounted with the menu, and a dialog mounted at the section
rather than inside the menu has no floating-tree ancestor to walk back to. A
keyboard user loses their place mid-list and a screen reader announces nothing.

Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It sits
in the controller or wrapper, e.g. between the confirm controller and the model call:
`remove` records which row went, and `finalFocus` picks the row that took its
place, the last row when it was the last, or `fallback()` once the list is empty.
The view receives `registerTrigger` and `finalFocus` as props and passes
`finalFocus` to the block, which resolves it when the dialog closes, after the
row has gone:

```ts
const sectionRef = useRef<HTMLDivElement>(null);
const removalFocus = useListRemovalFocus({
ids: methods.map(method => method.id),
onRemove,
fallback: () => sectionRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])') ?? sectionRef.current,
});
const remove = useConfirmController<Method>({ onConfirm: method => removalFocus.remove(method.id) });

return { sectionRef, remove, registerTrigger: removalFocus.registerTrigger, finalFocus: removalFocus.finalFocus };
```

```tsx
// each row's trigger
triggerRef={registerTrigger(method.id)}

// the dialog
<Confirmation controller={remove} finalFocus={finalFocus} … />
```

A cancelled or failed removal keeps its own trigger, so `finalFocus` returns
`null` and the dialog's default (the trigger) applies.

Prefer the row that took the removed one's place, the last row when it was the
last, and a control that outlives the list once it is empty.

Test the removal, not just the cancel: `toHaveFocus()` on the row that should
have caught it. A suite that only asserts focus after cancelling passes while
every successful removal drops focus on the floor.

## Rules

- **No Clerk imports.** If a controller needs a Clerk fact, the model supplies it
Expand All @@ -223,8 +296,8 @@ the feature's loading behavior; it does not require restructuring every dialog.
One path, so every error a user sees goes through the `errors.*` catalog:

- **Owners map, nothing else does.** A failure is owned by one of
`usePendingAction` (button and row actions), `useConfirmationController`,
`useDestructiveController`, or `useForm` (forms). The first three call
`usePendingAction` (button and row actions), `useConfirmController` (confirm
dialogs), or `useForm` (forms). The first two call
`toLocalizableError(cause)` from `utils/errors.ts`. Use one of them instead of
catching in a feature. A controller with its own machine maps in the machine's
`onError` (`user-profile-rename-passkey.controller.ts`).
Expand Down
5 changes: 4 additions & 1 deletion packages/mosaic/docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ drops the fallback instead of holding the space open. Keep the two apart.
- A callback that backs a form wraps its Clerk call in `save(run, fields, params)`
from `utils/errors.ts`. It rethrows a Clerk error as a `SaveError` routed to
the form fields it names, which the form renders
(`user-profile-profile-section.model.ts`).
(`user-profile-profile-section.model.ts`). `save` lets reverification errors
through, so the callback can be wrapped in `useReverificationFlow`. A model
that formats its own errors does the same with `isReverificationError` from
the same file.
- No local UI state. What is open and what is in flight belong to the controller.
- No React state machinery beyond the Clerk hooks themselves; the model is a
derivation of what Clerk currently says. The exception is what to fetch: a
Expand Down
80 changes: 28 additions & 52 deletions packages/mosaic/docs/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,29 +67,37 @@ use, and takes the rest as props. There are two:
and compares it.
- `blocks/confirmation` (`Confirmation`) is a confirm step with no typed phrase.

`open`, the pending flag and `errorMessage` come from the block's own controller
(`useDestructiveController`, `useConfirmationController`), because those decide
whether the dialog closes or explains itself. Spread the controller onto the
block rather than wiring that state by hand:
Both take a `controller` from `useConfirmController` (`blocks/confirm/`), which
decides whether the dialog closes or explains itself. The hook runs in the
wrapper or controller, never in the view; the view receives the controller as a
prop. A dialog next to its button takes the button as `trigger`:

```tsx
const destructive = useDestructiveController({ onDelete, reverification });

<Destructive
{...destructive}
controller={deleteAccount}
trigger={<Button color='negative'>{m.actionLabel}</Button>}
title={m.dialogTitle}
confirmationValue={m.fieldPlaceholder}
/>;
/>
```

When one dialog serves every row in a list, use the handle form: create a handle
with `Confirmation.createHandle<Row>()` (or `Destructive.createHandle`), pass it
to each row's trigger and to the block's `handle` prop, and the clicked row
arrives as the payload of `onConfirm` / `onDelete`. In this form the block runs
its own controller, so pass the effect and `errorFallback` straight to it.
`user-profile-mfa-section.view.tsx` and `api-keys-table.view.tsx` are worked
examples.
When one dialog serves every row in a list, the controller carries the row as
its payload. Mount the block once after the list, open it from a row with
`controller.open(row)`, and derive the copy from the payload. `onConfirm` in the
wrapper receives the same row. See "Removing a row takes focus with it" in
`controllers.md` for the focus hand-off that comes with a removal:

```tsx
<ActionMenu actions={[{ label: m.remove, onClick: () => remove.open(row) }]} />

<Confirmation
controller={remove}
title={member => fill(m.removeTitle, { name: member.name })}
description={m.removeDescription}
actionLabel={m.remove}
finalFocus={finalFocus}
/>
```

## Pure derivation belongs beside the view, not in it

Expand All @@ -99,43 +107,11 @@ rearranges a list, are decisions with no React in them. They live in

## Removing a row takes focus with it

A dialog returns focus to its trigger on close. When the action removes the row
that trigger sits on — signing a device out, removing an email — the trigger
unmounts and focus falls to `<body>`. Nothing catches it: the menu item that
opened the dialog unmounted with the menu, and a dialog mounted at the section
rather than inside the menu has no floating-tree ancestor to walk back to. A
keyboard user loses their place mid-list and a screen reader announces nothing.

Hand focus to a surviving element with `useListRemovalFocus` (`hooks/`). It
records which row was removed and, through `finalFocus`, picks the row that took
its place, the last row when it was the last, or `fallback()` once the list is
empty. `finalFocus` on `Dialog.Popup` and on the `Confirmation` block takes a
function resolved when the dialog closes, after the row has gone:

```tsx
const removalFocus = useListRemovalFocus({
ids: methods.map(method => method.id),
onRemove,
fallback: () => section.current?.querySelector<HTMLButtonElement>('button:not([disabled])') ?? section.current,
});

// each row's trigger
triggerRef={removalFocus.registerTrigger(method.id)}

// the dialog
finalFocus={removalFocus.finalFocus}
onConfirm={method => removalFocus.remove(method.id)}
```

A cancelled or failed removal keeps its own trigger, so `finalFocus` returns
`null` and the dialog's default (the trigger) applies.

Prefer the row that took the removed one's place, the last row when it was the
last, and a control that outlives the list once it is empty.

Test the removal, not just the cancel: `toHaveFocus()` on the row that should
have caught it. A suite that only asserts focus after cancelling passes while
every successful removal drops focus on the floor.
When a confirmed action removes the row its trigger sits on, focus has to be
handed to a surviving element. The bookkeeping lives in the controller, with
`useListRemovalFocus`; see `controllers.md`. The view's part is to register each
row's trigger with the `registerTrigger` it is given and pass `finalFocus` to the
block.

## Testing

Expand Down
25 changes: 25 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,31 @@ export function takeUnsettledHolds(): string[] {
return holds.map(hold => hold.name);
}

/** Answers the next POST to `path` with a reverification demand, then lets later requests through. */
export function demandReverificationOnce(path: string): void {
let demanded = false;
worker.use(
http.post(fapiUrl(path), () => {
if (demanded) {
return undefined;
}
demanded = true;
return HttpResponse.json(
{
errors: [
{
code: 'session_reverification_required',
message: 'Verification required',
long_message: 'Verification required',
},
],
},
{ status: 403 },
);
}),
);
}

export function holdRequests(method: 'get' | 'post', path: string): HeldRequests {
const requests: Request[] = [];
let resolveGate: (response: Response | undefined) => void = () => {};
Expand Down
Loading
Loading