Skip to content
Open
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/friendly-identifier-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 32 additions & 31 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,37 +32,38 @@ A flow lives in `src/features/<feature>/`. The **model** (`*.model.ts(x)`) is th

Most problems a feature hits already have a shared answer. Find the row for your problem and use it. Do not write a local version, even a small one.

| Problem | Use |
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) |
| A form: values, validation, submit, field errors, success UI | `useForm` (`src/components/form/`), with `validateAsync` for server checks and `onSuccess` for mounted success UI |
| A `<form>` element, without the browser's validation popup | `Form` (`src/primitives/form/`) |
| A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` |
| 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 model, passed to `useConfirmController` |
| A direct action that needs step-up verification | `useReverificationFlow` in the model, with `ReverificationDialog` (`src/blocks/reverification/`) in the view |
| Showing any error to the user | One of the owners above. They call `toLocalizableError` (or `toLocalizableErrors` for all errors) 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/`) |
| A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) |
| A placeholder for content that is still loading | `Skeleton` (`src/components/skeleton/`), see [Skeletons](docs/skeletons.md) |
| A date shown to the user | `formatDate` (`src/utils/format-date.ts`) |
| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) |
| Current time for display labels | `useNow` (`src/hooks/`) |
| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) |
| An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) |
| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` |
| An organization permission checked with `checkAuthorization` | `ORGANIZATION_PERMISSIONS` (`src/utils/permissions.ts`) |
| A verification code's length or resend cooldown | `CODE_LENGTH`, `CODE_RESEND_SECONDS` (`src/utils/verification.ts`) |
| An avatar or logo file picker and its rejection errors | `AVATAR_UPLOAD`, `AVATAR_REJECTION_ERRORS` (`src/utils/avatar-upload.ts`) |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) |
| A panel that animates open and closed to its measured size | `usePanelSize` (`src/primitives/hooks/`) |
| One screen replacing another on the same card or dialog | `Flow` (`src/components/flow/`), 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` |
| Problem | Use |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) |
| Direct actions and confirmation dialogs that must exclude each other | `usePendingAction.guard` on the confirmation (`src/hooks/`, `src/blocks/confirm/`) |
| A form: values, validation, submit, field errors, success UI | `useForm` (`src/components/form/`), with `validateAsync` for server checks and `onSuccess` for mounted success UI |
| A `<form>` element, without the browser's validation popup | `Form` (`src/primitives/form/`) |
| A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` |
| 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 model, passed to `useConfirmController` |
| A direct action that needs step-up verification | `useReverificationFlow` in the model, with `ReverificationDialog` (`src/blocks/reverification/`) in the view |
| Showing any error to the user | One of the owners above. They call `toLocalizableError` (or `toLocalizableErrors` for all errors) 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/`) |
| A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) |
| A placeholder for content that is still loading | `Skeleton` (`src/components/skeleton/`), see [Skeletons](docs/skeletons.md) |
| A date shown to the user | `formatDate` (`src/utils/format-date.ts`) |
| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) |
| Current time for display labels | `useNow` (`src/hooks/`) |
| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) |
| An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) |
| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` |
| An organization permission checked with `checkAuthorization` | `ORGANIZATION_PERMISSIONS` (`src/utils/permissions.ts`) |
| A verification code's length or resend cooldown | `CODE_LENGTH`, `CODE_RESEND_SECONDS` (`src/utils/verification.ts`) |
| An avatar or logo file picker and its rejection errors | `AVATAR_UPLOAD`, `AVATAR_REJECTION_ERRORS` (`src/utils/avatar-upload.ts`) |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) |
| A panel that animates open and closed to its measured size | `usePanelSize` (`src/primitives/hooks/`) |
| One screen replacing another on the same card or dialog | `Flow` (`src/components/flow/`), 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` |

## Signs you are about to invent a pattern

Expand Down
36 changes: 34 additions & 2 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,44 @@ 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
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
Loading
Loading