-
Notifications
You must be signed in to change notification settings - Fork 459
feat(ui): Add machine library for authoring flows #8919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
987a9be
init
alexcarpenter 803ea09
wip
alexcarpenter b5b9b37
Update README.md
alexcarpenter 40913f4
Create ADOPTION.md
alexcarpenter 2c0cbc6
feat(ui): harden Mosaic machine type safety and migrate org sections
alexcarpenter 76df631
add useMachineLogger
alexcarpenter 9ca083c
fix(ui): restart actor after stop so send works through StrictMode re…
alexcarpenter 1f7ff0b
fix(ui): reset actor to initial state on restart to prevent invoke re…
alexcarpenter 15788ac
fix(ui): use ref wrapper to prevent stale closure in section machine …
alexcarpenter 8245c15
feat(ui): add actor.setContext and sync options.context via useLayout…
alexcarpenter a521f2e
add changeset
alexcarpenter 0266948
Revert "add changeset"
alexcarpenter 0ab48ad
feat(ui): add TStates generic and constrain on keys to TEvent['type']
alexcarpenter 7663f1a
Create SKILL.md
alexcarpenter 99c8634
feat(ui): add onDone callback to useMachine
alexcarpenter dc92867
Update leave-organization.tsx
alexcarpenter ffc1130
feat(ui): add waitFor utility to mosaic machine
alexcarpenter cfe984c
feat(ui): add after (delayed transitions) to mosaic state machine
alexcarpenter 72bb8b5
chore(ui): remove waitFor — no production usage yet
alexcarpenter e1c94a8
wip
alexcarpenter bf5d62f
feat(ui): add fromPromise to setup() for typed invoke output
alexcarpenter 6741647
docs(ui): document useEffect → machine migration patterns
alexcarpenter 9e5a06c
fix(ui): display error feedback after failed org delete/leave
alexcarpenter a87a507
fix(ui): harden machine library and add SAFETY comments to type casts
alexcarpenter a02e312
feat(ui): add machine-backed mosaic flow controllers
alexcarpenter dbfbc35
fix(ui): optimize machine library footprint with nanostores patterns
alexcarpenter 9a3cee7
Merge branch 'main' into carp/mosaic-state-machine
alexcarpenter 83f7ec8
Merge branch 'carp/mosaic-state-machine' of https://github.com/clerk/…
alexcarpenter 0c1d783
feat(ui): add inline transition function form to Mosaic state machine
alexcarpenter 380bd93
Merge branch 'main' into carp/mosaic-state-machine
alexcarpenter e1fd1ba
fix(ui): address CodeRabbit review comments on Mosaic machine PR
alexcarpenter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| --- | ||
| name: mosaic-machine | ||
| description: > | ||
| Author and use Mosaic state machines. Use when the user is writing a state machine | ||
| with createMachine, modelling a multi-step flow, wiring a machine to React with | ||
| useMachine/useActor/useSelector, debugging a machine transition, or migrating from | ||
| useState booleans to a machine. | ||
| --- | ||
|
|
||
| # Mosaic Machine | ||
|
|
||
| > **XState-first rule:** Before designing any library feature or changing any API, look up how XState v5 handles the same pattern and align to it. Never invent new API shapes. | ||
|
|
||
| Core imports live in `packages/ui/src/mosaic/machine/`. | ||
|
|
||
| ```ts | ||
| import { setup } from './setup'; // primary: pre-binds TContext + TEvent | ||
| import { createActor, mockActor } from './createActor'; | ||
| import { useMachine, useActor, useSelector } from './useMachine'; | ||
|
|
||
| // Lower-level (only when not using setup): | ||
| import { createMachine } from './createMachine'; | ||
| import { assign } from './assign'; | ||
| ``` | ||
|
|
||
| `setup<TContext, TEvent>()` returns `{ createMachine, assign, fromPromise }`. Use `fromPromise` for promise-based `invoke` configurations — it carries the resolved type to `e.output` in `onDone.actions`. | ||
|
|
||
| --- | ||
|
|
||
| ## Anatomy | ||
|
|
||
| Use `setup<TContext, TEvent>()` at the top of each machine file. It pre-binds | ||
| both type parameters, returning a typed `createMachine` and `assign` so you | ||
| never have to restate them at call sites. | ||
|
|
||
| ```ts | ||
| import { setup } from './setup'; | ||
|
|
||
| // 1. Define context type — flat object, null defaults for optional fields. | ||
| interface MyContext { | ||
| data: string | null; | ||
| error: string | null; | ||
| } | ||
|
|
||
| // 2. Define the event union — SCREAMING_SNAKE_CASE types. | ||
| type MyEvent = { type: 'FETCH' } | { type: 'RETRY' } | { type: 'RESET' }; | ||
|
|
||
| // 3. Pre-bind types once for the file. | ||
| const { createMachine, assign, fromPromise } = setup<MyContext, MyEvent>(); | ||
|
|
||
| // 4. Factory when async deps are needed; plain createMachine() when not. | ||
| export function createMyMachine(fetchData: () => Promise<string>) { | ||
| return createMachine({ | ||
| // no <MyContext, MyEvent> needed | ||
| id: 'my', | ||
| initial: 'idle', | ||
| context: { data: null, error: null }, | ||
| states: { | ||
| idle: { | ||
| on: { FETCH: 'loading' }, | ||
| }, | ||
| loading: { | ||
| // fromPromise carries the resolved type to e.output in onDone.actions. | ||
| // A raw src function also works — e.output is `any` in that case. | ||
| invoke: fromPromise(() => fetchData(), { | ||
| onDone: { | ||
| target: 'success', | ||
| // e.output: string — typed from fetchData's return type, no cast needed | ||
| actions: assign((_, e) => ({ data: e.output, error: null })), | ||
| }, | ||
| onError: { | ||
| target: 'failure', | ||
| // e: ErrorInvokeEvent — inferred, no import needed | ||
| actions: assign((_, e) => ({ error: String(e.error) })), | ||
| }, | ||
| }), | ||
| }, | ||
| success: { type: 'final' }, | ||
| failure: { | ||
| on: { RETRY: 'loading', RESET: 'idle' }, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
| ``` | ||
|
|
||
| `assign`'s second type parameter is inferred from its position: | ||
|
|
||
| - Inside `on['SOME_EVENT']` → narrowed to that event member (e.g. `e.value` is safe) | ||
| - Inside `fromPromise(...).onDone` → `DoneInvokeEvent<TOutput>` where `TOutput` is the src return type | ||
| - Inside `onError` → `ErrorInvokeEvent` | ||
| - Inside `after[delay]` → `AfterEvent` | ||
|
|
||
| You do **not** need to import or write `DoneInvokeEvent`, `ErrorInvokeEvent`, `AfterEvent`, | ||
| or `Extract<Event, { type: 'X' }>` in machine files. | ||
|
|
||
| --- | ||
|
|
||
| ## Do's | ||
|
|
||
| **Model states, not booleans.** Replace `isOpen + isDeleting + isError` with explicit states — `idle → confirming → deleting → deleted`. Impossible combinations become unrepresentable. | ||
|
|
||
| **Define machines at module level or in a factory function.** They're static descriptions; creating inside a component recreates the object on every render (harmless for `useMachine` due to its `useRef` guard, but confusing and wasteful). | ||
|
|
||
| **Inject async deps via a factory, not module-level closure.** | ||
|
|
||
| ```ts | ||
| // ✓ factory — testable, no import-time side effects | ||
| export const createDeleteOrgMachine = (destroyFn: () => Promise<void>) => createMachine({ ... }); | ||
|
|
||
| // ✗ module-level capture — hard to test, couples to module load order | ||
| const machine = createMachine({ states: { deleting: { invoke: { src: () => someGlobal.destroy() } } } }); | ||
| ``` | ||
|
|
||
| **Use `assign` for context updates.** It's a pure `(context, event) => Partial<context>` — the runtime merges the patch. | ||
|
|
||
| **Use `invoke` for async work.** Actions are synchronous side effects only; promises in actions are invisible to the machine. | ||
|
|
||
| **Gate navigation with state-node `guard`.** Every transition targeting the state checks it automatically — no per-transition boilerplate. | ||
|
|
||
| ```ts | ||
| states: { | ||
| step2: { | ||
| guard: (ctx) => ctx.step1Complete, // blocks all entry to step2 | ||
| on: { NEXT: 'step3', PREV: 'step1' }, | ||
| }, | ||
| } | ||
| ``` | ||
|
|
||
| **Test in plain JS.** Drive `createActor → start → send` with no React. Reach unreachable/transient states with `mockActor`: | ||
|
|
||
| ```ts | ||
| const actor = mockActor(machine, { value: 'deleting', context: { error: null } }); | ||
| expect(actor.getSnapshot().value).toBe('deleting'); | ||
| ``` | ||
|
|
||
| **Use `actor.recheck()` when external data a guard reads changes.** It re-seats to the derived initial if the current state's guard no longer holds, or fires any pending `always` transition. | ||
|
|
||
| --- | ||
|
|
||
| ## Don'ts | ||
|
|
||
| **Don't do async work in `actions`.** Promises returned from an action function are dropped — the machine never sees the resolved value. | ||
|
|
||
| **Don't mutate context directly in actions.** Side effects only; use `assign` to update context. | ||
|
|
||
| **Don't track "impossible" state in context.** If you find yourself checking `isDeleting && isOpen`, add a state instead of adding a guard on a context flag. | ||
|
|
||
| **Don't pass an async function captured at module definition time.** It can't be stubbed in tests, and it breaks the pattern of injecting live props. | ||
|
|
||
| --- | ||
|
|
||
| ## React patterns | ||
|
|
||
| ### `useMachine` — own a flow for the component's lifetime | ||
|
|
||
| ```tsx | ||
| function DeleteOrganization({ organization }: { organization: Org }) { | ||
| const [snapshot, send] = useMachine(deleteOrgMachine, { | ||
| // `context` is kept current via useLayoutEffect — safe to pass live props/functions. | ||
| context: { destroyFn: () => organization.destroy() }, | ||
| // `onDone` fires once when the machine reaches a `type: 'final'` state. | ||
| onDone: () => router.navigate('/dashboard'), | ||
| }); | ||
|
|
||
| return ( | ||
| <ConfirmDialog | ||
| open={snapshot.value === 'confirming' || snapshot.value === 'deleting'} | ||
| onOpenChange={isOpen => send({ type: isOpen ? 'OPEN' : 'CANCEL' })} | ||
| isDeleting={snapshot.value === 'deleting'} | ||
| onConfirm={() => send({ type: 'CONFIRM' })} | ||
| error={snapshot.context.error} | ||
| /> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| Branch on `snapshot.value` for UI, not on `snapshot.context` booleans. | ||
|
|
||
| `onDone` always calls the latest prop — no stale-closure risk. Do not replace it with a `useEffect` watching `snapshot.status`. | ||
|
|
||
| ### `useActor` — bind to a shared actor | ||
|
|
||
| Use when the actor's lifecycle is owned by a parent or context provider. | ||
|
|
||
| ```tsx | ||
| function StepIndicator({ actor }: { actor: WizardActor }) { | ||
| const [snapshot] = useActor(actor); | ||
| return <Breadcrumb currentStep={snapshot.value} />; | ||
| } | ||
| ``` | ||
|
|
||
| ### `useSelector` — subscribe to a slice | ||
|
|
||
| Re-renders only when the selected value changes (by `Object.is`). Primary way to consume a shared actor without full-snapshot coupling. | ||
|
|
||
| ```tsx | ||
| const error = useSelector(actor, snap => snap.context.error); | ||
| const isDeleting = useSelector(actor, snap => snap.value === 'deleting'); | ||
| ``` | ||
|
|
||
| ### Injecting live props | ||
|
|
||
| `useMachine` calls `actor.setContext(options.context)` via `useLayoutEffect` after every render. Pass functions from props without recreating the machine: | ||
|
|
||
| ```tsx | ||
| // The machine reads `ctx.onSuccess` — always the latest prop. | ||
| const [snapshot, send] = useMachine(machine, { context: { onSuccess: props.onSuccess } }); | ||
| ``` | ||
|
|
||
| ### Debug logging (remove before shipping) | ||
|
|
||
| ```tsx | ||
| import { useMachineLogger } from './useMachine'; | ||
|
|
||
| const [snapshot, send] = useMachine(machine); | ||
| useMachineLogger('myFlow', snapshot); // logs: [myFlow] idle → loading { data: null } | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.