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-docs-model-before-api.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 @@ -73,6 +73,8 @@ Most problems a feature hits already have a shared answer. Find the row for your
| Backup codes saved to a file or printed | `downloadBackupCodes`, `printBackupCodes` (`src/utils/backup-codes.ts`) |
| An avatar or logo file picker and its rejection errors | `AVATAR_UPLOAD`, `AVATAR_REJECTION_ERRORS` (`src/utils/avatar-upload.ts`) |
| An avatar or logo row: picker, drop target, Upload button or manage menu | `AvatarUpload` (`src/blocks/avatar-upload/`), laid out by the caller |
| Any other file picker or drop zone, e.g. a metadata or key file | `FileUpload` (`src/primitives/file-upload/`), see its `README.md` |
| An inline notice inside content: info, warning, error or success | `Banner` (`src/components/banner/`); a form-level error on a card uses `Card.Banner` |
| 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/`) |
| Arrow-key navigation with one tab stop across a group of items | `useComposite`, with `useCompositeItems` and `useCompositeItem` (`src/primitives/hooks/`) |
Expand Down
50 changes: 50 additions & 0 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,56 @@ Context holds the `ErrorDescription`, never copy (see "Errors"). A machine whose
surface shows no error still logs it, so a failure is never silent
(`features/switcher/switcher.controller.tsx`).

### Multi-step flows

A flow with several steps on one surface is one machine and one form. No existing
flow follows every rule below yet, so each points at the closest example.

- **The machine owns the step.** Each step is a state, and the controller derives
the step the view shows from the snapshot, so an async state maps to the screen
that stays up while it runs (`viewStep` in `reverification.controller.ts`). No
`useState` step beside it, and no separate open flag.
- **Guards live in the machine.** What allows a step to continue is a guard on its
event. The controller reads it with `actor.can(event)` rather than writing a
`canContinue` that repeats it.
- **Async work is a named state with `invoke`.** Testing a connection is a
`testing` state, not a `pending` field in context and not a call in a transition
action. Its result lands in context from `onDone`, beside the state that waited
for it, not in a `usePendingAction` flag (`starting` and `submitting` in
`reverification.controller.ts`).
- **One `useForm` holds every step's values.** Its `onSubmit` is the last step's
save, and `canSubmit` covers only that save (`create-organization.controller.ts`).
Values are not copied into machine context with `TYPE` events.
- **Navigation is machine events.** Continue and back send `NEXT` and `BACK` with
the values the guard needs, and the machine chooses the next step. The
controller routes the form's submit by step: a middle step sends `NEXT`, and
only the last step runs `form.handleSubmit`, so Enter never saves early.

```ts
const [snapshot, send, actor] = useMachine(machine, { context: { testConnection: model.onTestConnection } });
const form = useForm<SsoValues, void>({ initialValues, onSubmit: values => model.onSave(values) });
const next = { type: 'NEXT', values: form.values } as const;

return {
step: viewStep(snapshot.value),
form,
isTesting: snapshot.value === 'testing',
canContinue: actor.can(next),
onSubmit: event => {
if (snapshot.value === 'review') {
form.handleSubmit(event);
return;
}
event.preventDefault();
send(next);
},
onBack: () => send({ type: 'BACK' }),
};
```

The view side, one `Flow` and a `SubmitButton` footer, is in `views.md` →
"Moving between screens on one surface".

## Responsibilities

- **Hold the interaction state.** In React state or a machine — see "Which one
Expand Down
39 changes: 39 additions & 0 deletions packages/mosaic/docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,45 @@ drops the fallback instead of holding the space open. Keep the two apart.
query and returns to page 1). The controller keeps the interaction around it,
such as the input's value and the debounce, and calls that callback.

## Before the API exists

A feature built from a design before Clerk can back it still starts with its
model. Write the model's return type first, the same shape a real model returns:
a `status`, plain data and callbacks. The model hook comes when the API does.

- **`Pick` what Clerk already has.** Search `@clerk/shared/types` before writing a
field. A new design often needs types Clerk already has, such as
`OrganizationEnrollmentMode` or `EnterpriseConnectionResource`.
- **Name the rest for the resource it will become.** A type Clerk does not have
yet is named for that future resource (`EnterpriseConnectionTest`), not for the
screen (`AccessData`). When the resource lands, the type becomes a `Pick`.
- **The swingset fixture implements that type.** It builds a ready model and
passes it to the feature's real controller and view
(`CreateOrganizationReadyModel` in
`packages/swingset/src/stories/create-organization.stories.tsx`).
- **Composition stays in Mosaic.** The wrapper, and whatever it renders around
the view (a title, a back action, a footer), lives in the feature. Swingset
supplies data and nothing else, because code there has no feature test and is
thrown away when the model lands.

```ts
export type SsoConnection = Pick<EnterpriseConnectionResource, 'id' | 'name' | 'provider' | 'active' | 'domains'>;

export interface EnterpriseConnectionTest {
success: boolean;
email?: string;
}

export type SsoModel =
| { status: 'loading' }
| {
status: 'ready';
connections: SsoConnection[];
enrollmentMode: OrganizationEnrollmentMode;
onTestConnection: (connectionId: string) => Promise<EnterpriseConnectionTest>;
};
```

## Time-dependent display data

Use the shared `useNow` hook and `MosaicProvider` clock support introduced in
Expand Down
11 changes: 9 additions & 2 deletions packages/mosaic/docs/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,20 @@ return makes the new screen pop in instead.
- **The surface sits outside the flow, and a step renders only what goes inside
it.** A screen used as a step of another feature cannot bring its own `Card.Root`.
`ConfirmShell` owns the card and `Reverification` renders only its contents.
- **Nest a flow when a step has steps of its own.** The outer flow keeps its step
mounted, and `useFlowAutoFocus` reaches the inner active step.
- **One flow per surface.** A step that has steps of its own adds them to the same
flow, so the view renders one `Flow.Root`.
- **The controller owns the step.** Going back is direction `-1`. Derive it from the
step when each step has one way in, and hold it in the controller when it does not
(`reverification.controller.ts`).
- **Mark what takes focus with `useFlowAutoFocus`.** Focus moves only for a step
that transitions in, so a flow that opens on a step does not take focus.
- **A multi-step flow gets derived props.** The view renders the `step`, `form`
and flags the controller passes and does not work out the step itself. Every
step's `Form` uses `id={form.id}` and the controller's `onSubmit`, and its
footer action is a `SubmitButton` with `form={form.id}`. A middle step sets
`isPending={isTesting}` and `disabled={!canContinue}`, and the last sets
`isPending={form.isSubmitting}` and `disabled={!form.canSubmit}`. The
controller side is in `controllers.md` → "Multi-step flows".

A loading or error state of a single screen is not a step, and neither is the
skeleton shown before a feature has data. Those can stay conditionals.
Expand Down
Loading