Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b6d4280
test(mosaic): cover conflicting contact primary actions
austincalvelage Oct 27, 2026
a7486b6
fix(mosaic): coordinate identifier mutations across action owners
austincalvelage Oct 27, 2026
028f04f
fix(mosaic): keep primary actions pending without reordering rows
austincalvelage Oct 28, 2026
612632a
refactor(mosaic): pass confirmation disabled state directly
austincalvelage Oct 28, 2026
325ce97
docs(mosaic): trim shared action lock guidance
austincalvelage Oct 28, 2026
4251133
refactor(mosaic): share pending ownership for identifier actions
austincalvelage Oct 28, 2026
9a21c72
refactor(mosaic): coordinate identifier actions in section controllers
austincalvelage Oct 28, 2026
1b760aa
fix(mosaic): list the primary identifier first and clear stale action…
austincalvelage Oct 28, 2026
39651c0
refactor(mosaic): let controllers own identifier row pending state
austincalvelage Oct 29, 2026
bacf95e
fix(mosaic): close the identifier menu once primary reverification opens
austincalvelage Oct 29, 2026
12ed0d1
fix(mosaic): hold identifier row order when the primary changes
austincalvelage Oct 29, 2026
c724da6
test(mosaic): fold guard tests into the usePendingAction suite
austincalvelage Oct 29, 2026
74bd089
style(mosaic): format contributor guidance
austincalvelage Nov 3, 2026
901cb1e
fix(mosaic): hold contact verify and MFA actions under one section lock
austincalvelage Oct 29, 2026
83ac3d7
fix(mosaic): let account section controllers own row pending state
austincalvelage Oct 29, 2026
7866673
style(mosaic): format contributor guidance
austincalvelage Nov 3, 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/friendly-identifier-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/goofy-parents-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Most problems a feature hits already have a shared answer. Find the row for your
| Deciding which part renders `Card.Root` | Only the surface, never a screen or `*Steps`, see [Views](docs/views.md) |
| An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) |
| Styles several components share | `src/styles/*.styles.ts` |
| Direct actions and confirmation dialogs that must exclude each other | `usePendingAction.guard` on the confirmation (`src/hooks/`, `src/blocks/confirm/`) |
| A machine-driven step that a lock or caller must await | `waitFor` (`src/machine/wait-for.ts`), inside `usePendingAction.run` |

## Signs you are about to invent a pattern

Expand Down
45 changes: 40 additions & 5 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,52 @@ It owns the error (see "Errors"):
const actions = usePendingAction({ errorFallback: messages.errors.generic });

return {
rows: rows.map(row => (actions.errorKey === row.id ? { ...row, error: actions.error } : row)),
pendingId: actions.pendingKey,
rows: rows.map(row => ({
...row,
isPending: actions.pendingKey === row.id,
error: actions.errorKey === row.id ? actions.error : undefined,
})),
onRemove: id => void actions.run(id, () => onRemove(id)),
};
```

When a direct action and a confirmation must not run together, give both
operations one `usePendingAction` owner:

```ts
const actions = usePendingAction();
const remove = useConfirmController<UserProfileEmail>({
onConfirm: contact => removalFocus.remove(contact.id),
});

return {
contacts: contacts.map(contact => ({ ...contact, isPrimaryPending: actions.pendingKey === contact.id })),
isDisabled: actions.isPending,
remove: actions.guard(remove),
};
```

Keep this coordination in the section controller. Run direct actions with
`actions.run(key, action)` and pass the confirmation through `actions.guard`, which
runs its `confirm` under the same lock. Pass `actions.isPending` to the section
controls' `disabled` props.

`confirm` resolves `true` when the operation succeeds, or `false` when it fails or
the dialog ignores the call. Its promise follows the operation even if the
dialog closes during cancellation. The shared pending owner waits for that
promise, so closing the dialog does not enable another action before cancellation
settles. Removal keeps its dialog errors, and inline row errors and focus keys
stay unchanged. Put each row's pending state on the row the controller returns,
so views render it without comparing pending keys.

`run` resolves `true` on success, so the caller can close a dialog or move
focus. To hold pending through an OAuth redirect, await a delay inside the
action (`user-profile-connected-accounts-section.controller.ts`). When the user
cancels a reverification challenge, `run` resolves `false` without showing an
error and releases the lock. It is not for:
action (`user-profile-connected-accounts-section.controller.ts`). To hold it
through a step a machine drives, run an action that awaits `waitFor`
(`machine/wait-for.ts`) until the machine leaves that state
(`user-profile-add-email.controller.ts`). When the user cancels a
reverification challenge, `run` resolves `false` without showing an error and
releases the lock. It is not for:

- a form → `useForm`
- a confirm step → `useConfirmController` with `Confirmation` / `Destructive`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,45 @@ describe('confirmMachine', () => {
});

describe('useConfirmController', () => {
it.each(['resolve', 'reject'])(
'keeps its completion pending after cancellation until the action can %s',
async outcome => {
const pending = deferred<void>();
const { result } = renderHook(() =>
useConfirmController({
onConfirm: () => pending.promise,
reverification: { status: 'loading', visible: true, onCancel: vi.fn() },
}),
);
const completed = vi.fn();
act(() => result.current.onOpenChange(true));
let completion: ReturnType<typeof result.current.confirm> | undefined;
act(() => {
completion = result.current.confirm();
});
expect(completion).toBeInstanceOf(Promise);
void completion?.then(completed);
act(() => result.current.onOpenChange(false));
await act(async () => {
await Promise.resolve();
});
expect(result.current.isOpen).toBe(false);
expect(completed).not.toHaveBeenCalled();

await act(async () => {
if (outcome === 'reject') {
pending.reject(cancelled);
} else {
pending.resolve();
}
await completion;
});

expect(completed).toHaveBeenCalledWith(outcome === 'resolve');
expect(result.current.errorMessage).toBeUndefined();
},
);

it('ignores open while no block is mounted on the controller, and keeps one handle across renders', () => {
const { result, rerender } = renderHook(() => useConfirmController({ onConfirm: () => Promise.resolve() }));
const { handle } = result.current;
Expand All @@ -107,17 +146,23 @@ describe('useConfirmController', () => {
const onConfirm = vi.fn(() => pending.promise);
const { result } = renderHook(() => useConfirmController({ onConfirm }));

act(() => result.current.confirm());
await act(async () => expect(await result.current.confirm()).toBe(false));
expect(onConfirm).not.toHaveBeenCalled();

act(() => result.current.onOpenChange(true));
act(() => result.current.confirm());
act(() => result.current.confirm());
const confirm = result.current.confirm;
let completion: ReturnType<typeof confirm> | undefined;
let duplicate: ReturnType<typeof confirm> | undefined;
act(() => {
completion = confirm();
duplicate = confirm();
});
expect(onConfirm).toHaveBeenCalledOnce();
expect(await duplicate).toBe(false);

await act(async () => {
pending.resolve();
await pending.promise;
expect(await completion).toBe(true);
});
await waitFor(() => expect(result.current.isOpen).toBe(false));
});
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/src/blocks/confirm/confirm-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function ConfirmCard<Payload>({
cancelLabel,
isPending: controller.isPending,
errorMessage: controller.errorMessage,
confirm: () => controller.confirm(payload),
confirm: () => void controller.confirm(payload),
});

return (
Expand Down
20 changes: 13 additions & 7 deletions packages/mosaic/src/blocks/confirm/confirm.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export interface ConfirmController<Payload = void> {
/** Opens the dialog about `payload` */
open: (payload: Payload) => void;
onOpenChange: (open: boolean) => void;
/** Runs the action for `payload`. The dialog closes when it resolves and shows why when it rejects. */
confirm: (payload: Payload) => void;
/** Runs the action for `payload`. The dialog closes when it resolves and shows why when it rejects. Resolves whether the action succeeded. */
confirm: (payload: Payload) => Promise<boolean>;
}

export interface ConfirmControllerOptions<Payload = void> {
Expand Down Expand Up @@ -136,15 +136,21 @@ export function useConfirmController<Payload = void>({
send({ type: 'CANCEL' });
},
confirm: payload => {
if (snapshot.value !== 'confirming') {
return;
}
let completion: Promise<boolean> | undefined;
send({
type: 'CONFIRM',
run: async () => {
await onConfirm(payload);
run: () => {
const operation = (async () => {
await onConfirm(payload);
})();
completion = operation.then(
() => true,
() => false,
);
return operation;
},
});
return completion ?? Promise.resolve(false);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ describe('Confirmation', () => {
await user.click(confirmButton());
expect(onConfirm).toHaveBeenCalledOnce();
expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
expect(confirmButton()).toHaveFocus();

run.resolve();
await closed();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function useUserProfileActiveDevicesController({
registerTrigger: removalFocus.registerTrigger,
finalFocus: removalFocus.finalFocus,
details,
signOutFromDetails: revoke ? device => details.confirm(device) : undefined,
signOutFromDetails: revoke ? device => void details.confirm(device) : undefined,
signOut: revoke ? signOut : undefined,
signOutAll: revokeAll ? signOutAll : undefined,
// Confirming removes the other devices and the trigger with them — hence the dialog mounted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,27 @@ describe('connected accounts', () => {
request.fail();
});

it('holds connected account menus while a connection starts', async () => {
await renderSection([google]);
const request = holdRequests('post', '/v1/me/external_accounts');
const user = userEvent.setup();

try {
await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
await waitFor(() => expect(request.requests).toHaveLength(1));
const manage = screen.getByRole('button', { name: 'Manage Google' });
expect(manage).toHaveAttribute('aria-disabled', 'true');
await user.click(manage);
expect(screen.queryByRole('menu')).toBeNull();
} finally {
request.fail();
}

await waitFor(() =>
expect(screen.getByRole('button', { name: 'Manage Google' })).not.toHaveAttribute('aria-disabled', 'true'),
);
});

it('verifies the session before connecting a provider, then continues to it', async () => {
serveFapi(signedIn([], { verification: { secrets: { password: 'hunter2' } } }));
const view = await renderWithClerk(<UserProfileConnectedAccountsSection />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,11 @@ import type {

export function UserProfileConnectProviderRowView({
provider,
isPending,
isDisabled,
onConnect,
triggerRef,
}: {
provider: UserProfileConnectionProvider;
isPending: boolean;
isDisabled: boolean;
onConnect: (id: string) => void;
triggerRef?: Ref<HTMLButtonElement>;
Expand Down Expand Up @@ -58,8 +56,8 @@ export function UserProfileConnectProviderRowView({
variant='outline'
color='neutral'
aria-label={fill(m.connectLabel, { provider: provider.provider })}
isPending={isPending}
disabled={isDisabled && !isPending}
isPending={provider.isPending}
disabled={isDisabled && !provider.isPending}
onClick={() => onConnect(provider.id)}
>
{m.connect}
Expand Down Expand Up @@ -91,7 +89,7 @@ export function UserProfileConnectedAccountRowView({
}) {
const m = useMessages('userProfile').connectedAccounts;
const actions: ActionMenuAction[] = [];
if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) {
if ((account.status === 'reconnect' || account.status === 'error') && onReconnect) {
actions.push({
label: account.status === 'reconnect' ? m.reconnect : m.tryAgain,
onClick: () => onReconnect(account.id),
Expand Down Expand Up @@ -131,6 +129,8 @@ export function UserProfileConnectedAccountRowView({
{actions.length > 0 ? (
<Section.Actions>
<ActionMenu
disabled={isDisabled}
focusableWhenDisabled
triggerRef={triggerRef}
label={fill(m.manageLabel, { provider: account.provider })}
actions={actions}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface UserProfileConnectedAccountsControllerOptions {
export interface UserProfileConnectedAccountsController {
accounts: UserProfileConnectedAccount[];
availableProviders: UserProfileConnectionProvider[];
pendingId: string | undefined;
isDisabled: boolean;
onConnect: (id: string) => void;
onReconnect: (id: string) => void;
sectionRef: Ref<HTMLDivElement>;
Expand Down Expand Up @@ -81,15 +81,17 @@ export function useUserProfileConnectedAccountsController({
accounts: accounts.map(account =>
actions.errorKey === account.id ? { ...account, reconnectError: actions.error } : account,
),
availableProviders: availableProviders.map(provider =>
actions.errorKey === provider.id ? { ...provider, connectError: actions.error } : provider,
),
pendingId: actions.pendingKey,
availableProviders: availableProviders.map(provider => ({
...provider,
isPending: actions.pendingKey === provider.id,
connectError: actions.errorKey === provider.id ? actions.error : undefined,
})),
isDisabled: actions.isPending,
onConnect: id => run(id, onConnect),
onReconnect: id => run(id, onReconnect),
sectionRef,
registerTrigger: removalFocus.registerTrigger,
remove: onRemove ? remove : undefined,
remove: onRemove ? actions.guard(remove) : undefined,
finalFocus: removalFocus.finalFocus,
reconnectReverification,
reconnectFinalFocus: () => (actions.lastKey ? removalFocus.trigger(actions.lastKey) : null),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ConnectedAccountProviderDisplay {
export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay {
id: string;
connectError?: string;
isPending?: boolean;
}

export interface UserProfileConnectedAccount extends ConnectedAccountProviderDisplay {
Expand All @@ -30,7 +31,7 @@ export interface UserProfileConnectedAccountsSectionViewProps {
accounts: UserProfileConnectedAccount[];
sectionRef?: Ref<HTMLDivElement>;
availableProviders?: UserProfileConnectionProvider[];
pendingId?: string;
isDisabled?: boolean;
onConnect?: (id: string) => void;
onReconnect?: (id: string) => void;
registerTrigger: (id: string) => Ref<HTMLButtonElement>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function UserProfileConnectedAccountsSectionView({
accounts,
sectionRef,
availableProviders = [],
pendingId,
isDisabled = false,
onConnect,
onReconnect,
registerTrigger,
Expand All @@ -31,7 +31,6 @@ export function UserProfileConnectedAccountsSectionView({
}: UserProfileConnectedAccountsSectionViewProps) {
const m = useMessages('userProfile').connectedAccounts;
const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect));
const isBusy = pendingId !== undefined;

return (
<>
Expand All @@ -52,7 +51,7 @@ export function UserProfileConnectedAccountsSectionView({
key={account.id}
account={account}
triggerRef={registerTrigger(account.id)}
isDisabled={isBusy}
isDisabled={isDisabled}
onReconnect={onReconnect}
onRemove={remove ? account => remove.open(account) : undefined}
/>
Expand All @@ -63,8 +62,7 @@ export function UserProfileConnectedAccountsSectionView({
key={provider.id}
provider={provider}
triggerRef={registerTrigger(provider.id)}
isPending={pendingId === provider.id}
isDisabled={isBusy}
isDisabled={isDisabled}
onConnect={onConnect}
/>
))
Expand Down
Loading
Loading