diff --git a/.changeset/mosaic-state-machine.md b/.changeset/mosaic-state-machine.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-state-machine.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic-machine/SKILL.md b/.claude/skills/mosaic-machine/SKILL.md new file mode 100644 index 00000000000..1aed7a36d1a --- /dev/null +++ b/.claude/skills/mosaic-machine/SKILL.md @@ -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()` returns `{ createMachine, assign, fromPromise }`. Use `fromPromise` for all `invoke` configurations — it carries the resolved type to `e.output` in `onDone.actions`. + +--- + +## Anatomy + +Use `setup()` 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(); + +// 4. Factory when async deps are needed; plain createMachine() when not. +export function createMyMachine(fetchData: () => Promise) { + return createMachine({ + // no 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` 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` 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) => 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` — 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 ( + 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 ; +} +``` + +### `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 } +``` diff --git a/packages/ui/src/mosaic/machine/ADOPTION.md b/packages/ui/src/mosaic/machine/ADOPTION.md new file mode 100644 index 00000000000..e5236bd8586 --- /dev/null +++ b/packages/ui/src/mosaic/machine/ADOPTION.md @@ -0,0 +1,825 @@ +# Why adopt the Mosaic state machine? + +The [README](./README.md) explains **how** to use the library. This document +answers **why** — by walking through three real migrations from today's +`@clerk/ui` code, showing what collapses and what the numbers look like. + +The README already covers the delete-organization flow (`idle → confirming → +deleting`) and the ConfigureSSO Wizard (see the worked example in the `
` +block and the parity test at +[`__tests__/wizard-migration.test.tsx`](./__tests__/wizard-migration.test.tsx)). +Everything below is a fresh example. + +--- + +## The pattern this library replaces + +Two patterns appear over and over in `@clerk/ui` flows: + +1. **`useLoadingStatus`** — a hand-rolled 3-state machine: + + ```ts + // hooks/useLoadingStatus.ts + type Status = 'idle' | 'loading' | 'error'; + export const useLoadingStatus = () => { + const [state, setState] = useSafeState({ status: 'idle' as Status }); + return { + status: state.status, + setIdle: () => setState({ status: 'idle' }), + setError: () => setState({ status: 'error' }), + setLoading: () => setState({ status: 'loading' }), + isLoading: state.status === 'loading', + isIdle: state.status === 'idle', + }; + }; + ``` + + It IS a state machine — three named states with named transitions — but + it has no guards (nothing stops `setLoading()` from being called while + already loading), no `invoke` (the caller must coordinate `setLoading` / + `setError` / `setIdle` manually), and it's bound to React state so it can + only be driven and tested by rendering. + +2. **Parallel state objects** — forms pair `useLoadingStatus` with + `useCardState` (a second implicit machine for card-level error display). + The two must be kept in sync by hand in every handler: + + ```ts + // WaitlistForm.tsx (simplified) + const status = useLoadingStatus(); + const card = useCardState(); + + const handleSubmit = async e => { + status.setLoading(); + card.setLoading(); + try { + await clerk.joinWaitlist({ emailAddress }); + wizard.nextStep(); + } catch (error) { + handleError(error, [emailField], card.setError); + } finally { + status.setIdle(); + card.setIdle(); + } + }; + ``` + + Four coordinated state calls surrounding every async operation. Miss one + and the UI hangs in loading. + +--- + +## Migration 1 (Simple): async submit form — `WaitlistForm` + +**Archetype:** `idle → submitting → success | error` + +**Source:** `components/Waitlist/WaitlistForm.tsx` + +### Before (real code, abridged) + +```tsx +const status = useLoadingStatus(); // 'idle' | 'loading' | 'error' +const card = useCardState(); // separate error/loading state for the card +const wizard = useWizard(); // step 0 = form, step 1 = success screen + +const handleSubmit = async e => { + e.preventDefault(); + status.setLoading(); + card.setLoading(); + card.setError(undefined); + await clerk + .joinWaitlist({ emailAddress: formState.emailAddress.value }) + .then(() => { + wizard.nextStep(); + if (ctx.afterJoinWaitlistUrl) { + setTimeout(() => navigate(ctx.afterJoinWaitlistUrl), 2000); + } + }) + .catch(error => handleError(error, [formState.emailAddress], card.setError)) + .finally(() => { + status.setIdle(); + card.setIdle(); + }); +}; +``` + +Problems: + +- Two state objects (`status` + `card`) must be driven in lockstep. +- `finally` is load-bearing: forget it and the button spins forever. +- `wizard.nextStep()` is a third piece of state in a third abstraction. +- Nothing prevents calling `status.setLoading()` while already in `loading` + (e.g. a double-submit race). +- No React-free test path — every assertion requires rendering. + +### After + +```ts +import { createMachine } from '@/mosaic/machine/createMachine'; +import { assign } from '@/mosaic/machine/assign'; + +type Context = { error: string | null }; +type Event = { type: 'SUBMIT'; emailAddress: string } | { type: 'NAVIGATE_DONE' }; + +const waitlistMachine = createMachine({ + id: 'waitlist', + initial: 'idle', + context: { error: null }, + states: { + idle: { + on: { SUBMIT: 'submitting' }, + }, + submitting: { + // Double-submit is impossible: SUBMIT is not handled here. + invoke: { + src: (ctx, event) => + clerk.joinWaitlist({ emailAddress: (event as Extract).emailAddress }), + onDone: { + target: 'success', + actions: assign(() => ({ error: null })), + }, + onError: { + target: 'idle', + actions: assign((_ctx, e) => ({ error: String(e.error) })), + }, + }, + }, + success: { + // Optional auto-navigate on entry: + entry: [ + (ctx, event) => { + if (afterJoinWaitlistUrl) { + setTimeout(() => navigate(afterJoinWaitlistUrl), 2000); + } + }, + ], + }, + }, +}); +``` + +In React: + +```tsx +const [snapshot, send] = useMachine(waitlistMachine); + + { + e.preventDefault(); + send({ type: 'SUBMIT', emailAddress: formState.emailAddress.value }); + }} +> + + {snapshot.context.error && {snapshot.context.error}} +; +``` + +### What collapsed + +| Before | After | +| ---------------------------------------- | ------------------------------------------------- | +| 2 state objects (`status` + `card`) | 1 machine | +| 4 manual state calls per submit | 1 `send({ type: 'SUBMIT' })` | +| `finally` block (load-bearing) | gone — `invoke` always exits `submitting` | +| Double-submit possible | impossible — `SUBMIT` not handled in `submitting` | +| Logic testable only with React | pure actor test, no rendering needed | +| `isLoading` derived from `status.status` | `snapshot.value === 'submitting'` | + +**Net: 2 `useState` instances deleted, 1 hand-rolled hook replaced, impossible +state (both `status.isLoading` and `card.isLoading` out of sync) eliminated.** + +--- + +## Migration 2 (Medium): modal/selection soup — `APIKeysPage` + +**Archetype:** `closed | revoking(payload) | copying(payload)` — mutually +exclusive UI modes carrying typed context + +**Source:** `components/APIKeys/APIKeys.tsx` + +### Before (real code, abridged) + +```tsx +const [apiKey, setAPIKey] = useState(null); +const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false); +const [selectedAPIKeyID, setSelectedAPIKeyID] = useState(''); +const [selectedAPIKeyName, setSelectedAPIKeyName] = useState(''); +const [isCopyModalOpen, setIsCopyModalOpen] = useState(false); + +const handleRevoke = (apiKeyID: string, apiKeyName: string) => { + setSelectedAPIKeyID(apiKeyID); + setSelectedAPIKeyName(apiKeyName); + setIsRevokeModalOpen(true); +}; + +const handleCreateAPIKey = async params => { + // …create key… + setIsCopyModalOpen(true); + setAPIKey(apiKey); + // … +}; +``` + +Problems: + +- 5 `useState` calls, but only 3 logical states: `closed`, `revoking`, `copying`. +- Both modal booleans can be `true` at once — no code prevents it. +- `selectedAPIKeyID` and `selectedAPIKeyName` are only meaningful when + `isRevokeModalOpen` is true; in `closed` state they're stale strings. +- `apiKey` is only meaningful when `isCopyModalOpen` is true. +- The close handler for revoke must zero out three pieces of state atomically: + ```tsx + onClose={() => { + setSelectedAPIKeyID(''); + setSelectedAPIKeyName(''); + setIsRevokeModalOpen(false); + }} + ``` + Forget one and the modal re-opens with stale data. + +### After + +```ts +type ModalContext = + | { mode: 'closed' } + | { mode: 'revoking'; id: string; name: string } + | { mode: 'copying'; apiKey: APIKeyResource }; + +type ModalEvent = + | { type: 'REVOKE'; id: string; name: string } + | { type: 'COPY'; apiKey: APIKeyResource } + | { type: 'CLOSE' }; + +const apiKeyModalMachine = createMachine<{ modal: ModalContext }, ModalEvent>({ + id: 'apiKeyModal', + initial: 'closed', + context: { modal: { mode: 'closed' } }, + states: { + closed: { + on: { + REVOKE: { + target: 'revoking', + actions: assign((_ctx, e) => ({ modal: { mode: 'revoking', id: e.id, name: e.name } })), + }, + COPY: { + target: 'copying', + actions: assign((_ctx, e) => ({ modal: { mode: 'copying', apiKey: e.apiKey } })), + }, + }, + }, + revoking: { on: { CLOSE: { target: 'closed', actions: assign(() => ({ modal: { mode: 'closed' } })) } } }, + copying: { on: { CLOSE: { target: 'closed', actions: assign(() => ({ modal: { mode: 'closed' } })) } } }, + }, +}); +``` + +In React: + +```tsx +const [modal, send] = useMachine(apiKeyModalMachine); + +// Open revoke: + send({ type: 'REVOKE', id, name })} /> + +// The revoke modal — typed context, no stale strings: + send({ type: 'CLOSE' })} +/> + +// The copy modal — context guarantees apiKey is non-null when open: + send({ type: 'CLOSE' })} +/> +``` + +### What collapsed + +| Before | After | +| ------------------------------------------------ | ---------------------------------------------------- | -------- | -------- | +| 5 `useState` calls | 1 machine + 1 `useMachine` | +| Both modals can be open simultaneously | impossible — `closed | revoking | copying` | +| Stale `selectedAPIKeyID` when modal closed | impossible — payload only exists in `revoking` state | +| 3-call atomic close (must zero ID + name + bool) | 1 `send({ type: 'CLOSE' })` | +| `onClose` handler is load-bearing | gone — `CLOSE` transitions atomically | +| Logic testable only with React | pure actor test, no rendering needed | + +**Net: 5 `useState` calls → 1 machine. 3 impossible states made +unrepresentable. Atomic close replacing a fragile 3-setter dance.** + +--- + +## Migration 3 (Complex): coordinated flags — `SignInStart` + +**Archetype:** a `useLoadingStatus` machine glued to a conditional UI branch +with a coordinating flag — shows where to draw the machine boundary honestly + +**Source:** `components/SignIn/SignInStart.tsx` + +`SignInStart` has 8+ pieces of state. Not all of them belong in a machine — +this is the example that makes the case _and_ names the limits. + +### Before (real code, abridged) + +```tsx +const status = useLoadingStatus(); // 'idle' | 'loading' | 'error' +// … +const [alternativePhoneCodeProvider, setAlternativePhoneCodeProvider] = + useState(null); +// … +const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons); +const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false); +const [identifierAttribute, setIdentifierAttribute] = useState(…); +``` + +The `status + alternativePhoneCodeProvider` pair coordinates: when an +alternative phone-code provider is selected, the component renders a +provider-specific form; when that form submits, `status` goes to loading. +These two pieces of state define the real view-model of the component: + +``` + SELECT_PROVIDER +idle ──────────────────────────────► provider_selected + ▲ │ + │ CLEAR_PROVIDER / BACK │ SUBMIT + │ ▼ + └──────────────────────── submitting ◄──┘ + │ + onDone ──────┘──► idle (with side effects) + onError ─────────► idle (error in context) +``` + +### After (the coordinated subset) + +```ts +type SignInContext = { + selectedProvider: PhoneCodeChannelData | null; + error: string | null; +}; +type SignInEvent = + | { type: 'SELECT_PROVIDER'; provider: PhoneCodeChannelData } + | { type: 'CLEAR_PROVIDER' } + | { type: 'SUBMIT'; identifier: string }; + +const signInStartMachine = createMachine({ + id: 'signInStart', + initial: 'idle', + context: { selectedProvider: null, error: null }, + states: { + idle: { + on: { + SELECT_PROVIDER: { + target: 'provider_selected', + actions: assign((_ctx, e) => ({ selectedProvider: e.provider, error: null })), + }, + SUBMIT: 'submitting', + }, + }, + provider_selected: { + on: { + CLEAR_PROVIDER: { + target: 'idle', + actions: assign(() => ({ selectedProvider: null })), + }, + SUBMIT: 'submitting', + }, + }, + submitting: { + invoke: { + src: (_ctx, event) => signIn.create({ identifier: (event as any).identifier }), + onDone: { target: 'idle', actions: assign(() => ({ error: null })) }, + onError: { + target: 'idle', + actions: assign((_ctx, e) => ({ error: String(e.error) })), + }, + }, + }, + }, +}); +``` + +### What stays as `useState` (honest boundary) + +Not every piece of state in this component belongs in the machine: + +| State | Keep as `useState`? | Why | +| ----------------------- | ------------------- | ---------------------------------------------------------------- | +| `identifierAttribute` | yes | A UI selection with no async lifecycle — simple controlled input | +| `hasSwitchedByAutofill` | yes | A one-shot flag reset on the next user action, no transitions | +| `shouldAutofocus` | yes | Pure UI concern, no business logic | + +**The rule:** reach for a machine when state has an _async lifecycle_ (a +promise that must complete before transitioning) or when two or more pieces +of state are _mutually constraining_ (setting one must clear another). A +single boolean that never interacts with async is fine as `useState`. + +### What collapsed (the coordinated subset) + +| Before | After | +| -------------------------------------------------------------------- | ------------------------------------ | +| `useLoadingStatus` (React-bound) | machine state `submitting` | +| `alternativePhoneCodeProvider !== null` check | machine state `provider_selected` | +| Manual `setAlternativePhoneCodeProvider(null)` atomically with reset | `CLEAR_PROVIDER` transition | +| `status.setLoading() / setIdle() / setError()` | `invoke` (no manual calls) | +| No guard on double-submit | `SUBMIT` not handled in `submitting` | +| Logic testable only with React | pure actor test | + +**Net: `useLoadingStatus` + 1 `useState` → 1 machine. The machine draws an +explicit boundary; the 3 independent `useState`s above stay exactly where +they are.** + +--- + +## Migration 4 (Medium): competing entry points — social buttons + email + +**Archetype:** multiple triggers for the same async flow where the UI needs +to know _which_ trigger is active (to show a spinner on that specific control) + +**Source:** `components/SignIn/SignInStart.tsx` — the social OAuth buttons +plus the email/phone identifier form on the same screen. + +### Before + +Two hooks coordinate card-level and button-level loading state: + +```tsx +const status = useLoadingStatus(); // 'idle' | 'loading' | 'error' +const card = useCardState(); // separate card-level loading + error + +// Clicking a social button: +const handleOAuthClick = async strategy => { + status.setLoading(); + card.setLoading(); + try { + await signIn.authenticateWithRedirect({ strategy }); + } catch (err) { + handleError(err, [], card.setError); + } finally { + status.setIdle(); + card.setIdle(); + } +}; + +// Submitting the identifier form: +const handleSubmit = async e => { + status.setLoading(); + card.setLoading(); + try { + await signIn.create({ identifier }); + wizard.nextStep(); + } catch (err) { + handleError(err, [identifierField], card.setError); + } finally { + status.setIdle(); + card.setIdle(); + } +}; +``` + +The component then disables all buttons with `status.isLoading` and shows a +spinner on whichever button was clicked by passing the strategy down as a +separate prop or via a ref. Two sync calls per async entry point, a +load-bearing `finally` in each, and nothing that prevents both handlers +from calling `setLoading` simultaneously. + +### After + +A single `submitting` state with `activeStrategy` in context replaces both hooks: + +```ts +import { setup } from '@/mosaic/machine/setup'; + +interface SignInStartContext { + activeStrategy: OAuthStrategy | 'email' | null; + identifier: string; + error: string | null; + signInFn: (params: SignInCreateParams) => Promise; +} + +type SignInStartEvent = + | { type: 'CLICK_SOCIAL'; strategy: OAuthStrategy } + | { type: 'TYPE_IDENTIFIER'; value: string } + | { type: 'SUBMIT_IDENTIFIER' }; + +const { createMachine, assign } = setup(); + +export function createSignInStartMachine(deps: { signInFn: SignInStartContext['signInFn'] }) { + return createMachine({ + initial: 'idle', + context: { activeStrategy: null, identifier: '', error: null, signInFn: deps.signInFn }, + states: { + idle: { + on: { + CLICK_SOCIAL: { + target: 'submitting', + actions: assign((_, e) => ({ activeStrategy: e.strategy })), + }, + TYPE_IDENTIFIER: { + actions: assign((_, e) => ({ identifier: e.value, error: null })), + }, + SUBMIT_IDENTIFIER: { + target: 'submitting', + actions: assign(() => ({ activeStrategy: 'email' as const })), + }, + }, + }, + submitting: { + // Both entry points converge here. idle's on-handlers are inactive, + // so a second CLICK_SOCIAL while already submitting is dropped automatically. + invoke: { + src: ctx => + ctx.activeStrategy === 'email' + ? ctx.signInFn({ identifier: ctx.identifier }) + : ctx.signInFn({ strategy: ctx.activeStrategy! }), + onDone: 'success', + onError: { + target: 'idle', + actions: assign((_, e) => ({ error: String(e.error), activeStrategy: null })), + }, + }, + }, + success: { type: 'final' }, + }, + }); +} +``` + +In React, `isLocked` replaces `card.setLoading()` and `activeStrategy` +replaces the per-button `status.isLoading` check: + +```tsx +const [snapshot, send] = useMachine(signInStartMachine, { + context: { signInFn: params => signIn.create(params) }, + onDone: () => setActive({ session: signIn.createdSessionId }), +}); + +const isLocked = snapshot.value === 'submitting'; +const active = snapshot.context.activeStrategy; + +// Social buttons: +{oauthStrategies.map(strategy => ( + send({ type: 'CLICK_SOCIAL', strategy })} + /> +))} + +// Identifier form: + send({ type: 'TYPE_IDENTIFIER', value: e.target.value })} +/> + send({ type: 'SUBMIT_IDENTIFIER' })} +/> +{snapshot.context.error && {snapshot.context.error}} +``` + +### What collapsed + +| Before | After | +| ----------------------------------------------- | ------------------------------------------------------- | +| `card.setLoading()` (disables everything) | `snapshot.value === 'submitting'` | +| Per-button `status.isLoading` (which one spins) | `snapshot.context.activeStrategy === strategy` | +| `card.setError(msg)` | `snapshot.context.error` | +| `finally` block in each handler (load-bearing) | gone — `invoke` always exits `submitting` | +| Two handlers racing on `setLoading` | impossible — `CLICK_SOCIAL` not handled in `submitting` | +| Logic testable only with React | pure actor test, no rendering needed | + +**Net: `useLoadingStatus` + `useCardState` → 1 machine. "Which button is +spinning" falls out of context rather than requiring a separate tracking +mechanism. Simultaneous triggers made unrepresentable.** + +--- + +## Migration 5: useEffect as a machine smell — three patterns from SignInStart + +`useEffect(fn, [])` and `useLayoutEffect(fn, [deps])` in a component are almost +always a sign that logic belongs in a machine instead. `SignInStart.tsx` has +three concrete examples. + +### Pattern A — on-mount async + routing + +```tsx +// Before: a useEffect fires on mount, does async work, and imperatively navigates +useEffect(() => { + if (!organizationTicket) return; + signIn + .create({ strategy: 'ticket', ticket: organizationTicket }) + .then(res => { + if (res.status === 'needs_first_factor') return navigate('factor-one'); + if (res.status === 'needs_second_factor') return navigate('factor-two'); + // ... + }) + .catch(attemptToRecoverFromSignInError); +}, []); +``` + +The machine makes the async call _the initial state_. `actor.start()` fires it; +the component never needs a `useEffect` or an imperative `navigate`: + +```ts +createMachine({ + initial: 'redeeming', // ← fires on actor.start() + context: { ticket: '', pendingStatus: '' }, + states: { + redeeming: { + invoke: fromPromise(ctx => signIn.create({ strategy: 'ticket', ticket: ctx.ticket }), { + onDone: { + target: 'routing', + actions: assign((_, e) => ({ pendingStatus: e.output.status })), + }, + onError: { target: 'failed' }, + }), + }, + routing: { + always: [ + { target: 'firstFactor', guard: ctx => ctx.pendingStatus === 'needs_first_factor' }, + { target: 'secondFactor', guard: ctx => ctx.pendingStatus === 'needs_second_factor' }, + { target: 'complete' }, + ], + }, + firstFactor: {}, + secondFactor: {}, + complete: { type: 'final' }, + failed: {}, + }, +}); +``` + +The component renders `` while `snapshot.value === 'redeeming'` and +lets the router layer (which maps state → route) handle navigation. No domain +knowledge in the component. + +> Proved in `patterns.test.ts` — Pattern 7. + +--- + +### Pattern B — on-mount external state read → error + +```tsx +// Before: a useEffect fires on mount, reads external data, and imperatively sets error +useEffect(() => { + const error = signIn?.firstFactorVerification?.error; + if (error) { + card.setError(error); // mutates card state imperatively + void signIn.create({}); // workaround to reset the attempt + } +}, []); +``` + +The machine receives the external data as context at creation time. An `initial` +function routes immediately — no effect, no imperative call: + +```ts +createMachine({ + initial: ctx => (ctx.oauthError ? 'oauthError' : 'idle'), + context: { oauthError: null, ... }, + states: { + oauthError: { + // entry could fire the signIn.create({}) reset if needed + on: { DISMISS: 'idle' }, + }, + idle: { ... }, + }, +}); + +// At creation, pass the external error in: +const machine = createSignInStartMachine({ + signInFn: ..., + oauthError: signIn.firstFactorVerification?.error ?? null, +}); +``` + +The component reads `snapshot.context.oauthError` or branches on +`snapshot.value === 'oauthError'` — it never calls `card.setError`. + +--- + +### Pattern C — reactive value → auto-switch (replaces useLayoutEffect) + +```tsx +// Before: watches identifierField.value to auto-switch to phone input +useLayoutEffect(() => { + if ( + identifierField.value.startsWith('+') && + identifierAttributes.includes('phone_number') && + identifierAttribute !== 'phone_number' && + !hasSwitchedByAutofill + ) { + handlePhoneNumberPaste(identifierField.value); + setHasSwitchedByAutofill(true); // prevent re-triggering on subsequent autofills + } +}, [identifierField.value, identifierAttributes]); +``` + +The machine puts the same guard inside the `TYPE` event handler. The switch and +the loop-prevention flag (`hasAutoSwitched`) update atomically with the value: + +```ts +TYPE: { + actions: assign((ctx, e) => { + const shouldSwitch = + phoneEnabled && + e.value.startsWith('+') && + ctx.fieldType !== 'phone' && + !ctx.hasAutoSwitched; + if (shouldSwitch) { + return { value: e.value, fieldType: 'phone', hasAutoSwitched: true }; + } + return { value: e.value }; + }), +}, +SWITCH_FIELD: { + // Manual switch resets the guard so autofill can trigger once more + actions: assign(ctx => ({ + fieldType: ctx.fieldType === 'text' ? 'phone' : 'text', + hasAutoSwitched: false, + })), +}, +``` + +`hasSwitchedByAutofill` disappears as a `useState` — it's `ctx.hasAutoSwitched`, +collocated with the value that drives it. + +> Proved in `patterns.test.ts` — Pattern 8. + +--- + +### The rule + +| If you see… | It maps to… | +| ----------------------------------------------- | ----------------------------------------------------- | +| `useEffect(fn, [])` — async on mount | Initial state with `invoke` | +| `useEffect(fn, [])` — read external on mount | `initial` as a function, or `always` in initial state | +| `useLayoutEffect(fn, [value])` — react to value | `assign` guard inside the event that changes `value` | +| `useEffect(fn, [a, b])` — sync two values | Transition that updates both atomically | + +--- + +## When NOT to reach for a machine + +A machine earns its keep when **two or more** of these are true: + +- The state has an async lifecycle (a promise, a fetch, a mutation). +- Two or more values must transition atomically (setting one requires + clearing another). +- The same impossible combination would be a bug (both modals open, loading + AND showing an error from a previous submit). +- The logic needs to be tested without rendering. + +A machine is **overkill** when: + +- There's a single boolean with no guards or async. `const [isOpen, setIsOpen] += useState(false)` is fine. +- The state changes only in response to direct user input with no + side-effects (`identifierAttribute`, `shouldAutofocus`). +- You'd end up with a two-state machine (`true | false`) and a single event. + That's just a boolean. + +The test: if you can't draw a diagram with at least 3 states or an async +arrow, `useState` is probably the right tool. + +--- + +## How to migrate incrementally + +You don't have to commit to migrating every piece of state at once. The +patterns above show a few natural entry points: + +1. **Start with the submit path.** Any component that calls + `status.setLoading()` / `status.setIdle()` / `status.setError()` can have + those three calls replaced by an `idle → submitting` machine with `invoke`. + The rest of the component can stay unchanged. This is a low-risk first step + because you're replacing a known-bad pattern (manual `finally` calls) with + a safer one. + +2. **Then pull in the payload.** If the component also manages data that rides + with the async operation (an `apiKey`, an `error`), add `context` to the + machine. This is where the "impossible state" wins start to show up. + +3. **Finally, absorb the coordinating flags.** If any `useState` values always + get set or cleared atomically with the submit state, move them into the + machine's `context` and wire them up as transitions. This is the full + migration. + +--- + +## The proven precedent + +The ConfigureSSO Wizard was the most complex existing implicit machine in +`@clerk/ui`. It was a hand-rolled `reduce(state, event, config)` pure reducer +plus a React seam full of `useRef` mirrors and two "adjust-state-during- +render" passes. The migration parity test +([`__tests__/wizard-migration.test.tsx`](./__tests__/wizard-migration.test.tsx)) +confirms the machine version reproduces every behavior while discarding the +seam entirely. The comparison table at the bottom of the README's worked +example is worth reading if you're considering a similar migration. diff --git a/packages/ui/src/mosaic/machine/README.md b/packages/ui/src/mosaic/machine/README.md new file mode 100644 index 00000000000..cb77f078722 --- /dev/null +++ b/packages/ui/src/mosaic/machine/README.md @@ -0,0 +1,422 @@ +# Mosaic state machine + +A tiny, dependency-free helper for modelling **flows** — anything that moves +through a sequence of steps: a delete confirmation, a multi-step wizard, a +sign-in attempt, a "save" button that goes idle → saving → saved. + +You don't need to know state-machine theory to use it. This README starts from +the problem and builds up; the deep material is collapsed at the bottom. + +--- + +## Why bother? The problem it solves + +A flow is usually built from a pile of `useState` booleans: + +```tsx +const [isOpen, setIsOpen] = useState(false); +const [isDeleting, setIsDeleting] = useState(false); +const [error, setError] = useState(null); +const [confirmValue, setConfirmValue] = useState(''); +``` + +The trouble is that nothing stops the _impossible_ combinations — `isDeleting` +**and** `isOpen === false`, an `error` while still `isDeleting`. The rules +("you can only delete after confirming the name") live scattered across event +handlers, and you can't test any of it without rendering the component. + +A state machine flips that around: you list the **states** the flow can be in, +and the **events** that move between them. Impossible combinations become +unrepresentable, the rules live in one object, and you can drive the whole thing +in a plain test with no React at all. + +--- + +## The mental model in 60 seconds + +Four words. That's the whole vocabulary: + +| Term | Plain meaning | +| -------------- | ---------------------------------------------------------------------------------------- | +| **state** | A named step the flow can be in — `idle`, `loading`, `success`. Only ever one at a time. | +| **event** | Something that happens — a click, a fetch resolving. You `send` events. | +| **context** | The extra data that rides along — a typed value, an error, a result. | +| **transition** | A rule: "in state X, event Y takes you to state Z." | + +A flow that loads data has three steps and looks like this: + +``` + FETCH resolves + idle ─────────► loading ───────────► success + │ + │ rejects + ▼ + failure +``` + +That diagram _is_ the machine. The rest of this doc is how you write it down and +run it. + +--- + +## Your first machine + +```ts +import { createMachine } from '@/mosaic/machine/createMachine'; +import { assign } from '@/mosaic/machine/assign'; + +const loader = createMachine({ + initial: 'idle', // where it starts + context: { data: null, error: null }, // the data that rides along + states: { + idle: { + on: { FETCH: 'loading' }, // event FETCH → go to 'loading' + }, + loading: { + // `invoke` runs a promise on entry and branches on the result. + invoke: { + src: () => fetchThing(), + onDone: { + target: 'success', + // copy the resolved value into context + actions: assign((_ctx, event) => ({ data: event.output })), + }, + onError: { + target: 'failure', + actions: assign((_ctx, event) => ({ error: String(event.error) })), + }, + }, + }, + success: {}, + failure: { on: { FETCH: 'loading' } }, // allow a retry + }, +}); +``` + +That's the whole flow, as one readable object. Notice there's no way to be in +`loading` _and_ have an `error` — the shape forbids it. + +### Eliminating boilerplate with `setup` + +Once a machine grows beyond a few states, explicitly repeating the context and +event types on every `assign` call gets noisy: + +```ts +// Without setup — types must be restated every time +assign>((_, e) => ({ + query: e.query, +})); +``` + +`setup()` pre-binds both types once per file and returns +factory functions that don't require repeating them: + +```ts +import { setup } from '@/mosaic/machine/setup'; + +const { createMachine, assign } = setup(); + +const loader = createMachine({ + // no needed + initial: 'idle', + context: { query: '', data: null, error: null }, + states: { + idle: { + on: { + SET_QUERY: { + actions: assign((_, e) => ({ query: e.query })), // e: { type: 'SET_QUERY'; query: string } ✓ + }, + FETCH: 'loading', + }, + }, + loading: { + invoke: { + src: async ctx => fetchData(ctx.query), + onDone: { + target: 'success', + actions: assign((_, e) => ({ data: String(e.output), error: null })), // e: DoneInvokeEvent + }, + onError: { + target: 'failure', + actions: assign((_, e) => ({ error: String(e.error) })), // e: ErrorInvokeEvent + }, + }, + }, + success: {}, + failure: { on: { FETCH: 'loading' } }, + }, +}); +``` + +The `assign` returned by `setup` narrows the event type from its position: + +- Inside `on['SET_QUERY']` → `e: { type: 'SET_QUERY'; query: string }` +- Inside `onDone` → `e: DoneInvokeEvent` +- Inside `onError` → `e: ErrorInvokeEvent` +- Inside `after[delay]` → `e: AfterEvent` + +You no longer need to import `DoneInvokeEvent`, `ErrorInvokeEvent`, or `AfterEvent` +in machine files — they flow through contextual typing automatically. + +### Running it + +A machine on its own does nothing — it's just a description. To run it you wrap +it in an **actor** (the running instance): + +```ts +import { createActor } from '@/mosaic/machine/createActor'; + +const actor = createActor(loader); + +actor.subscribe(snapshot => { + console.log(snapshot.value); // 'idle' | 'loading' | 'success' | 'failure' + console.log(snapshot.context); // { data, error } +}); + +actor.start(); +actor.send({ type: 'FETCH' }); // → 'loading', then 'success'/'failure' when the promise settles +``` + +`actor.getSnapshot()` returns the current `{ value, context, status }` at any +time. `value` is the state name; `context` is the riding data. + +### In React + +`useMachine` does the create + start + subscribe for you: + +```tsx +import { useMachine } from '@/mosaic/machine/useMachine'; + +function Loader() { + const [snapshot, send] = useMachine(loader); + + if (snapshot.value === 'idle') return ; + if (snapshot.value === 'loading') return ; + if (snapshot.value === 'failure') return send({ type: 'FETCH' })} />; + return ; +} +``` + +The component is now a straight mapping from state → UI. No boolean juggling. + +--- + +## A few more building blocks + +You've already seen `states`, `on`, `context`, `assign`, and `invoke`. The rest: + +- **`guard`** — a condition on a transition. "CONFIRM only works once the typed + name matches": + ```ts + CONFIRM: { target: 'deleting', guard: context => context.confirmValue === context.name } + ``` +- **state-node `guard`** — a condition on _entering a state at all_ ("may + navigation land here right now?"). Every transition that targets the state + checks it. This is what gates wizard steps. +- **`always`** — an eventless transition that fires the moment its guard is true, + with no event sent. Good for "as soon as X is ready, move on." +- **`entry` / `exit`** — actions to run when a state is entered or left. +- **`type: 'final'`** — a terminal state; the flow is done, further events are + ignored. +- **`initial` as a function** — `(context) => stateId`, when the starting step is + computed (e.g. a wizard resuming on the furthest step the user has unlocked). + +### API at a glance + +| Export | What it is | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `createMachine(config)` | Build the definition. A plain, inert, inspectable object. | +| `assign(updater)` | A `(context, event) => Partial` context update, used inside `actions`. | +| `createActor(machine, options?)` | The running instance: `.start()`, `.stop()`, `.send(event)`, `.getSnapshot()`, `.subscribe(fn)`, `.can(event)`, `.recheck()`. | +| `mockActor(machine, { value, context })` | An actor teleported straight to any step — render a transient/unreachable state for docs and snapshots. | +| `useMachine(machine, options?)` | React: own an actor for the component's life → `[snapshot, send]`. Accepts `onDone` callback fired when the machine finishes. | +| `useActor(actor)` | React: bind to a **shared** actor → `[snapshot, send]`. | +| `useSelector(actor, selector, equals?)` | React: subscribe to one **slice** — re-renders only when that slice changes. | + +### Two behaviors worth knowing + +**Nothing happens on a no-op.** If an event is blocked by a guard or isn't +handled in the current state, the snapshot is returned _by reference unchanged_ +and subscribers are **not** notified. That's what lets `useSelector` skip +re-renders, and lets a caller detect "the flow didn't move" with a `===` check. + +**`recheck()` for guards that read the outside world.** A guard can read live +external data (an SWR cache, a store) through a closure, not just `context`. +When that data changes, call `actor.recheck()`. It re-seats the flow if the +current step is no longer valid, and fires any `always` transition that was +waiting on the data. (This is the seam the wizard's "the connection backing this +step was just deleted" handling sits on.) + +### Testing & docs + +Because a machine runs without React, most tests just drive the actor in plain +JS — see [`__tests__/machine.test.ts`](./__tests__/machine.test.ts). For a step +you can't easily click to (a `deleting` state hidden behind a 2-second mutation, +a guard-gated wizard step), `mockActor` drops you straight in: + +```ts +const actor = mockActor(loader, { value: 'failure', context: { error: 'Network error' } }); +render(); // snapshot a state you'd otherwise have to provoke +``` + +--- + +
+Worked example: migrating the ConfigureSSO Wizard (before → after) + +The wizard engine at `components/ConfigureSSO/elements/Wizard/` was hand-built as +a pure reducer (`reducer.ts`) plus a React seam (`useWizardMachine.ts`). It's the +strongest existing proof that this abstraction earns its keep, and it maps onto +the library one-to-one. The live migration test — +[`__tests__/wizard-migration.test.tsx`](./__tests__/wizard-migration.test.tsx) — +asserts the two are behaviorally identical. + +### What the wizard does + +Steps come from a runtime `descriptors[]` array. Each step has an **entry guard** +("may navigation land here?"). On load the wizard seats on the _furthest step +reachable by a contiguous run of holding guards_. `NEXT`/`PREV` move one slot; +`GOTO` jumps to any reachable step. + +### Before: a reducer + a React seam full of workarounds + +The pure reducer is clean: + +```ts +// reducer.ts (abridged) +type WizardEvent = { type: 'NEXT' } | { type: 'PREV' } | { type: 'GOTO'; step: string }; +const guardHolds = step => (step.guard ? step.guard() : true); + +export const reduce = (state, event, config) => { + const steps = config.descriptors; + switch (event.type) { + case 'NEXT': { + const i = steps.findIndex(s => s.id === state.current); + if (i < 0) return state; // unknown current → no-op + const next = steps[i + 1]; + if (!next) return state; // terminal → same ref (host bubbles) + if (!guardHolds(next)) return state; // blocked → same ref + return { current: next.id, direction: 1, hasNavigated: true }; + } + // …PREV mirrors NEXT; GOTO rejects unknown / current / blocked… + } +}; +``` + +The complexity is in the **React seam** — because the state lived inside React, +it needed ref-mirrors and two "adjust-state-during-render" passes: + +```ts +// useWizardMachine.ts (abridged) +const [state, setState] = useState(() => initialState(config)); +const [pendingNextFrom, setPendingNextFrom] = useState(null); + +const configRef = useRef(config); +configRef.current = config; // mirror for stable handlers +const stateRef = useRef(state); +stateRef.current = state; // mirror + +// #1 re-seat off a step whose guard broke (during render) +if (!isNested) { + /* …if current step's guard is false, setState(initialState(config))… */ +} + +// #2 resolve a parked NEXT once its guard catches up to a just-resolved await +if (pendingNextFrom !== null) { + /* …re-reduce NEXT against the FRESH config… */ +} + +const goNext = useCallback(() => { + const next = reduce(stateRef.current, { type: 'NEXT' }, configRef.current); + if (next !== stateRef.current) { + setState(next); + return; + } + // same-ref: terminal → bubble to parent, else blocked → park a deferred advance +}, []); +``` + +### After: a machine factory + +Each descriptor becomes a state node whose entry `guard` gates every transition +landing on it; `initial` is the derived furthest-reachable step. + +```ts +function createWizardMachine(descriptors) { + const ids = descriptors.map(d => d.id); + const guardHolds = d => (d.guard ? d.guard() : true); + + const furthestReachable = () => { + if (descriptors.length === 0) return ''; + let i = 0; + while (i + 1 < descriptors.length && guardHolds(descriptors[i + 1])) i++; + return descriptors[i].id; + }; + const navigated = direction => assign(() => ({ direction, hasNavigated: true })); + + const states = {}; + descriptors.forEach((d, i) => { + const nextId = ids[i + 1]; + const prevId = ids[i - 1]; + states[d.id] = { + guard: d.guard, // entry guard — gates every transition that targets this step + on: { + ...(nextId ? { NEXT: { target: nextId, actions: navigated(1) } } : {}), + ...(prevId ? { PREV: { target: prevId, actions: navigated(-1) } } : {}), + GOTO: descriptors + .filter(t => t.id !== d.id) + .map(t => ({ target: t.id, guard: (_c, e) => e.step === t.id, actions: navigated(0) })), + }, + }; + }); + + return createMachine({ + id: 'wizard', + initial: furthestReachable, + context: { direction: 0, hasNavigated: false }, + states, + }); +} +``` + +Driving it needs no refs and no render-phase passes: + +```ts +const actor = createActor(createWizardMachine(descriptors)); +actor.start(); // seats on the furthest-reachable step +actor.send({ type: 'NEXT' }); // advances iff the next step's entry guard holds +actor.recheck(); // external data changed → re-seat off a now-broken step +``` + +### What collapses, and why + +| Seam concern (before) | Machine primitive (after) | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reduce` event union + `guardHolds` | `on` transitions + state-node `guard` | +| `initialState` furthest-reachable walk | derived `initial: furthestReachable` | +| every no-op returns `=== state` | no-op = same snapshot ref + no notify, for free | +| `configRef` / `stateRef` render mirrors | the actor _is_ the live state — `send` reads it synchronously | +| re-seat clamp (adjust-state-during-render) | `actor.recheck()` | +| `pendingNextFrom` deferred advance | **gone** — that race only existed because React owned the state; an actor reads live guards synchronously, so `await mutation; send({ type: 'NEXT' })` just advances | +| terminal/first bubble detection | the same same-ref no-op signal (`!actor.can(event)` at a boundary) | + +The reducer modelled a state machine by hand; the seam existed only to paper over +React owning the state. Moving the state into an actor deletes that second half. + +### Stepper view by introspection + +The breadcrumb (`isCompleted` positional / `isReachable` guard-driven) reads +straight off `machine.states` — no running instance, which is how docs enumerate +every step without clicking through: + +```ts +const deriveStepper = (machine, current) => { + const ids = Object.keys(machine.states); + const currentIndex = ids.indexOf(current); + return ids.map((id, i) => ({ + id, + isCompleted: currentIndex >= 0 && i < currentIndex, + isReachable: machine.states[id].guard ? machine.states[id].guard() : true, + })); +}; +``` diff --git a/packages/ui/src/mosaic/machine/__tests__/delete-organization-machine.ts b/packages/ui/src/mosaic/machine/__tests__/delete-organization-machine.ts new file mode 100644 index 00000000000..39d5a1fb32c --- /dev/null +++ b/packages/ui/src/mosaic/machine/__tests__/delete-organization-machine.ts @@ -0,0 +1,111 @@ +import { assign } from '../assign'; +import { createMachine } from '../createMachine'; +import type { DoneInvokeEvent, ErrorInvokeEvent, StateMachine } from '../types'; + +/** + * Shared fixture: the delete-organization flow expressed as an explicit machine. + * + * Today this logic is smeared across four `useState` flags in + * `sections/delete-organization.tsx` + `block/destructive.tsx` + * (`open`, `isDeleting`, `confirmValue`, and the derived `canSubmit`). Modelling + * it as a machine — `idle → confirming → deleting → deleted`, guarded on the + * typed name matching, with an error path back to `confirming` — makes every + * state reachable and testable without rendering a single component. + * + * It is intentionally defined once and imported by both the runtime and the + * React tests so the two read against the same, real-world example. + */ + +export interface DeleteOrgContext { + /** The org name the user must type to confirm. */ + name: string; + /** What the user has typed into the confirm field so far. */ + confirmValue: string; + /** Populated when the destroy mutation rejects. */ + error: string | null; +} + +export type DeleteOrgEvent = + | { type: 'OPEN' } + | { type: 'TYPE'; value: string } + | { type: 'CONFIRM' } + | { type: 'CANCEL' }; + +/** The async work the `deleting` state invokes. */ +export type DestroyOrg = (context: DeleteOrgContext) => Promise; + +export function createDeleteOrgMachine(destroyOrg: DestroyOrg): StateMachine { + return createMachine({ + id: 'deleteOrg', + initial: 'idle', + context: { name: 'Acme Inc', confirmValue: '', error: null }, + states: { + idle: { + on: { OPEN: 'confirming' }, + }, + confirming: { + on: { + // Internal transition: runs an action, stays in `confirming`. + TYPE: { + actions: assign((_, event) => + event.type === 'TYPE' ? { confirmValue: event.value } : {}, + ), + }, + // Guarded: only proceeds once the typed name matches. + CONFIRM: { target: 'deleting', guard: context => context.confirmValue === context.name }, + CANCEL: { + target: 'idle', + actions: assign(() => ({ confirmValue: '', error: null })), + }, + }, + }, + deleting: { + invoke: { + src: destroyOrg, + onDone: 'deleted', + onError: { + target: 'confirming', + actions: assign((_, event) => ({ error: String(event.error) })), + }, + }, + }, + deleted: { type: 'final' }, + }, + }); +} + +/** + * A tiny loader flow used to demonstrate `invoke` landing its resolved output in + * context. Parameterised by the fetcher so tests can resolve, reject, or hold it. + */ +export interface LoaderContext { + data: string | null; + error: string | null; +} + +export type LoaderEvent = { type: 'FETCH' }; + +export function createLoaderMachine(fetcher: () => Promise): StateMachine { + return createMachine({ + initial: 'idle', + context: { data: null, error: null }, + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { + invoke: { + src: fetcher, + onDone: { + target: 'success', + actions: assign>((_, event) => ({ data: event.output })), + }, + onError: { + target: 'failure', + actions: assign((_, event) => ({ error: String(event.error) })), + }, + }, + }, + success: { type: 'final' }, + failure: { on: { FETCH: 'loading' } }, + }, + }); +} diff --git a/packages/ui/src/mosaic/machine/__tests__/machine.test.ts b/packages/ui/src/mosaic/machine/__tests__/machine.test.ts new file mode 100644 index 00000000000..ec0e3bb24d7 --- /dev/null +++ b/packages/ui/src/mosaic/machine/__tests__/machine.test.ts @@ -0,0 +1,831 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { assign, isAssignAction } from '../assign'; +import { createActor, mockActor } from '../createActor'; +import { createMachine } from '../createMachine'; +import type { DoneInvokeEvent, ErrorInvokeEvent } from '../types'; +import { + createDeleteOrgMachine, + createLoaderMachine, + type DeleteOrgContext, + type DeleteOrgEvent, +} from './delete-organization-machine'; + +/** Flush microtasks (and the macrotask queue) so invoked promises settle. */ +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +/** A promise whose resolution is controlled by the test (for in-flight assertions). */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('createMachine — introspection (the swingset seam)', () => { + it('exposes states, initial, context and id without running anything', () => { + const machine = createDeleteOrgMachine(() => Promise.resolve()); + + expect(machine.id).toBe('deleteOrg'); + expect(machine.initial).toBe('idle'); + expect(machine.context).toEqual({ name: 'Acme Inc', confirmValue: '', error: null }); + // Every step is enumerable statically — docs can navigate to each one. + expect(Object.keys(machine.states)).toEqual(['idle', 'confirming', 'deleting', 'deleted']); + }); + + it('defaults context to an empty object when omitted', () => { + const machine = createMachine({ initial: 'a', states: { a: {} } }); + expect(machine.context).toEqual({}); + }); +}); + +describe('createActor — transitions', () => { + it('starts in the initial state', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().status).toBe('active'); + }); + + it('moves between states on handled events', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + + actor.send({ type: 'OPEN' }); + expect(actor.getSnapshot().value).toBe('confirming'); + + actor.send({ type: 'CANCEL' }); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('ignores events the current state does not handle', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + + // `idle` has no `CONFIRM` handler — the snapshot is untouched. + const before = actor.getSnapshot(); + actor.send({ type: 'CONFIRM' }); + expect(actor.getSnapshot()).toBe(before); + expect(actor.getSnapshot().value).toBe('idle'); + }); +}); + +describe('createActor — guards', () => { + it('blocks a transition while the guard fails and allows it once it passes', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + actor.send({ type: 'OPEN' }); + + // Typed name does not match yet → CONFIRM is blocked. + actor.send({ type: 'CONFIRM' }); + expect(actor.getSnapshot().value).toBe('confirming'); + + actor.send({ type: 'TYPE', value: 'Acme Inc' }); + actor.send({ type: 'CONFIRM' }); + expect(actor.getSnapshot().value).toBe('deleting'); + }); + + it('takes the first transition whose guard passes', () => { + type Ctx = { score: number }; + type Ev = { type: 'GRADE' }; + const machine = createMachine({ + initial: 'grading', + context: { score: 75 }, + states: { + grading: { + on: { + GRADE: [ + { target: 'a', guard: c => c.score >= 90 }, + { target: 'b', guard: c => c.score >= 70 }, + { target: 'c' }, + ], + }, + }, + a: {}, + b: {}, + c: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'GRADE' }); + expect(actor.getSnapshot().value).toBe('b'); + }); +}); + +describe('createActor — assign & action order', () => { + it('updates context via assign', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Ac' }); + expect(actor.getSnapshot().context.confirmValue).toBe('Ac'); + actor.send({ type: 'TYPE', value: 'Acme' }); + expect(actor.getSnapshot().context.confirmValue).toBe('Acme'); + }); + + it('runs multiple actions in order, later assigns seeing earlier updates', () => { + const order: string[] = []; + type Ctx = { count: number }; + type Ev = { type: 'GO' }; + const machine = createMachine({ + initial: 'a', + context: { count: 0 }, + states: { + a: { + on: { + GO: { + target: 'b', + actions: [ + assign(c => ({ count: c.count + 1 })), + c => order.push(`side-effect saw count=${c.count}`), + assign(c => ({ count: c.count * 10 })), + ], + }, + }, + }, + b: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'GO' }); + + expect(actor.getSnapshot().context.count).toBe(10); + expect(order).toEqual(['side-effect saw count=1']); + }); + + it('runs entry and exit actions around a transition', () => { + const log: string[] = []; + type Ev = { type: 'NEXT' }; + const machine = createMachine, Ev>({ + initial: 'a', + states: { + a: { exit: () => log.push('exit-a'), on: { NEXT: 'b' } }, + b: { entry: () => log.push('entry-b') }, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'NEXT' }); + expect(log).toEqual(['exit-a', 'entry-b']); + }); +}); + +describe('createActor — immediate (always) transitions', () => { + it('takes a guarded eventless transition on entry', () => { + type Ctx = { authed: boolean }; + type Ev = { type: 'LOGIN' }; + const machine = createMachine({ + initial: 'idle', + context: { authed: true }, + states: { + idle: { on: { LOGIN: 'checking' } }, + // No event handler — resolves immediately based on context. + checking: { + always: [{ target: 'granted', guard: c => c.authed }, { target: 'denied' }], + }, + granted: {}, + denied: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'LOGIN' }); + // Never observably rests in `checking`. + expect(actor.getSnapshot().value).toBe('granted'); + }); +}); + +describe('createActor — invoke (async)', () => { + it('lands the resolved output in context via onDone', async () => { + const actor = createActor(createLoaderMachine(() => Promise.resolve('payload'))); + actor.start(); + actor.send({ type: 'FETCH' }); + expect(actor.getSnapshot().value).toBe('loading'); + + await tick(); + expect(actor.getSnapshot().value).toBe('success'); + expect(actor.getSnapshot().context.data).toBe('payload'); + }); + + it('routes a rejection to onError with the error in context', async () => { + const actor = createActor(createLoaderMachine(() => Promise.reject(new Error('boom')))); + actor.start(); + actor.send({ type: 'FETCH' }); + + await tick(); + expect(actor.getSnapshot().value).toBe('failure'); + expect(actor.getSnapshot().context.error).toContain('boom'); + }); + + it('passes the resolved value to onDone as event.output', async () => { + const onDone = vi.fn(); + const machine = createMachine<{ out: string | null }, { type: 'GO' }>({ + initial: 'idle', + context: { out: null }, + states: { + idle: { on: { GO: 'running' } }, + running: { + invoke: { + src: () => Promise.resolve('the-output'), + onDone: { + target: 'done', + actions: [ + (_c, e) => onDone((e as DoneInvokeEvent).output), + assign<{ out: string | null }, DoneInvokeEvent>((_, e) => ({ out: e.output })), + ], + }, + }, + }, + done: { type: 'final' }, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'GO' }); + await tick(); + + expect(onDone).toHaveBeenCalledWith('the-output'); + expect(actor.getSnapshot().context.out).toBe('the-output'); + }); + + it('does not transition when stopped mid-flight (no work after stop)', async () => { + const gate = deferred(); + const actor = createActor(createLoaderMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'FETCH' }); + expect(actor.getSnapshot().value).toBe('loading'); + + actor.stop(); + gate.resolve('too-late'); + await tick(); + + expect(actor.getSnapshot().value).toBe('loading'); + expect(actor.getSnapshot().status).toBe('stopped'); + expect(actor.getSnapshot().context.data).toBeNull(); + }); + + it('abandons an in-flight invoke when the state is left via an event', async () => { + const gate = deferred(); + const machine = createMachine, { type: 'GO' } | { type: 'CANCEL' }>({ + initial: 'idle', + states: { + idle: { on: { GO: 'working' } }, + working: { + invoke: { src: () => gate.promise, onDone: 'done' }, + on: { CANCEL: 'idle' }, + }, + done: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'GO' }); + expect(actor.getSnapshot().value).toBe('working'); + + actor.send({ type: 'CANCEL' }); // leave `working` before the promise settles + expect(actor.getSnapshot().value).toBe('idle'); + + gate.resolve('late'); + await tick(); + expect(actor.getSnapshot().value).toBe('idle'); // stale onDone did not fire + }); +}); + +describe('createActor — final states', () => { + it('marks the snapshot done and ignores further events', async () => { + const actor = createActor(createLoaderMachine(() => Promise.resolve('x'))); + actor.start(); + actor.send({ type: 'FETCH' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('success'); + expect(actor.getSnapshot().status).toBe('done'); + + // `success` is final — even a normally-handled event is a no-op now. + actor.send({ type: 'FETCH' }); + expect(actor.getSnapshot().value).toBe('success'); + }); +}); + +describe('createActor — can()', () => { + it('reports whether the current state would handle an event', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + + expect(actor.can({ type: 'OPEN' })).toBe(true); + expect(actor.can({ type: 'CONFIRM' })).toBe(false); // not handled in idle + + actor.send({ type: 'OPEN' }); + // CONFIRM is handled but its guard fails → not currently takeable. + expect(actor.can({ type: 'CONFIRM' })).toBe(false); + expect(actor.can({ type: 'CANCEL' })).toBe(true); + + actor.send({ type: 'TYPE', value: 'Acme Inc' }); + expect(actor.can({ type: 'CONFIRM' })).toBe(true); // guard now passes + }); + + it('returns false once stopped', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + actor.stop(); + expect(actor.can({ type: 'OPEN' })).toBe(false); + }); +}); + +describe('createActor — state entry guards', () => { + // Entry guards read live external data via closure — the Wizard's core need. + function makeGatedMachine(canEnterB: () => boolean) { + return createMachine, { type: 'GO' }>({ + initial: 'a', + states: { + a: { on: { GO: 'b' } }, + b: { guard: canEnterB }, + }, + }); + } + + it('blocks a transition whose target entry guard fails — a true no-op (same ref, no notify)', () => { + const actor = createActor(makeGatedMachine(() => false)); + actor.start(); + const seen: string[] = []; + actor.subscribe(s => seen.push(s.value)); + + const before = actor.getSnapshot(); + actor.send({ type: 'GO' }); + + expect(actor.getSnapshot()).toBe(before); // referentially unchanged + expect(actor.getSnapshot().value).toBe('a'); + expect(seen).toEqual([]); // subscribers NOT notified on a no-op + }); + + it('allows the transition once the entry guard holds, and reflects it in can()', () => { + let open = false; + const actor = createActor(makeGatedMachine(() => open)); + actor.start(); + + expect(actor.can({ type: 'GO' })).toBe(false); + actor.send({ type: 'GO' }); + expect(actor.getSnapshot().value).toBe('a'); // still blocked + + open = true; + expect(actor.can({ type: 'GO' })).toBe(true); + actor.send({ type: 'GO' }); + expect(actor.getSnapshot().value).toBe('b'); + }); +}); + +describe('createActor — derived initial state', () => { + it('computes the start state from context via an initial resolver', () => { + const machine = createMachine<{ resumeAt: string }, { type: 'X' }>({ + initial: context => context.resumeAt, + context: { resumeAt: 'c' }, + states: { a: {}, b: {}, c: {} }, + }); + const actor = createActor(machine); + actor.start(); + expect(actor.getSnapshot().value).toBe('c'); + }); +}); + +describe('createActor — recheck (always re-evaluated on external-data change)', () => { + it('takes a parked always transition once external data lets its guard pass', () => { + let ready = false; + const machine = createMachine, { type: never }>({ + initial: 'waiting', + states: { + waiting: { always: { target: 'ready', guard: () => ready } }, + ready: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + // Guard was false on entry → still parked in `waiting`. + expect(actor.getSnapshot().value).toBe('waiting'); + + const seen: string[] = []; + actor.subscribe(s => seen.push(s.value)); + + actor.recheck(); // data not ready yet → no-op, no notify + expect(actor.getSnapshot().value).toBe('waiting'); + expect(seen).toEqual([]); + + ready = true; + actor.recheck(); // external data changed → advance + expect(actor.getSnapshot().value).toBe('ready'); + expect(seen).toEqual(['ready']); + }); + + it('re-seats to the resolved initial state when live data makes the current step unenterable', () => { + // Self-correction: the actor is sitting on a step whose entry guard reads live + // external data, and that data changes so the step is no longer enterable. A + // recheck() re-seats to the freshly-resolved initial state — the seam the + // Wizard's render-phase "clamp" stands on. + let bReachable = true; + const machine = createMachine, { type: never }>({ + initial: () => (bReachable ? 'b' : 'a'), + states: { + a: {}, + b: { guard: () => bReachable }, + }, + }); + const actor = createActor(machine); + actor.start(); + expect(actor.getSnapshot().value).toBe('b'); // initial resolver landed on b + + const seen: string[] = []; + actor.subscribe(s => seen.push(s.value)); + + actor.recheck(); // b still enterable → no-op, no notify + expect(actor.getSnapshot().value).toBe('b'); + expect(seen).toEqual([]); + + bReachable = false; + actor.recheck(); // b unenterable → re-seat to the resolved initial (a) + expect(actor.getSnapshot().value).toBe('a'); + expect(seen).toEqual(['a']); + }); + + it('does not re-seat while the current step is still enterable (a guard opening elsewhere does not yank)', () => { + // Mirrors the Wizard's "create-style change" invariant: a later guard going + // TRUE while the current step still holds must NOT move the user. + let bReachable = false; + const machine = createMachine, { type: never }>({ + initial: 'a', + states: { + a: {}, + b: { guard: () => bReachable }, + }, + }); + const actor = createActor(machine); + actor.start(); + const seen: string[] = []; + actor.subscribe(s => seen.push(s.value)); + + bReachable = true; // b becomes reachable, but a still holds → no re-seat + actor.recheck(); + expect(actor.getSnapshot().value).toBe('a'); + expect(seen).toEqual([]); + }); +}); + +describe('createActor — observable contract', () => { + it('notifies subscribers on each transition and stops after unsubscribe', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + + const seen: string[] = []; + const unsubscribe = actor.subscribe(snapshot => seen.push(snapshot.value)); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'CANCEL' }); + expect(seen).toEqual(['confirming', 'idle']); + + unsubscribe(); + actor.send({ type: 'OPEN' }); + expect(seen).toEqual(['confirming', 'idle']); // no further notifications + }); + + it('getSnapshot is referentially stable until a change occurs', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + + const first = actor.getSnapshot(); + expect(actor.getSnapshot()).toBe(first); // same reference, no change + + actor.send({ type: 'OPEN' }); + expect(actor.getSnapshot()).not.toBe(first); // new reference after transition + }); +}); + +describe('createActor — context init option', () => { + type Ctx = { label: string; count: number }; + type Ev = { type: 'GO' }; + + const machine = createMachine({ + initial: 'idle', + context: { label: 'default', count: 0 }, + states: { idle: { on: { GO: 'done' } }, done: { type: 'final' } }, + }); + + it('merges runtime context over machine defaults', () => { + const actor = createActor(machine, { context: { label: 'runtime' } }); + actor.start(); + expect(actor.getSnapshot().context).toEqual({ label: 'runtime', count: 0 }); + }); + + it('leaves unspecified fields at their machine defaults', () => { + const actor = createActor(machine, { context: { count: 42 } }); + actor.start(); + expect(actor.getSnapshot().context).toEqual({ label: 'default', count: 42 }); + }); + + it('snapshot.context takes precedence over context init', () => { + const actor = createActor(machine, { + context: { label: 'runtime' }, + snapshot: { value: 'idle', context: { label: 'teleport' } }, + }); + expect(actor.getSnapshot().context).toEqual({ label: 'teleport', count: 0 }); + }); +}); + +describe('mockActor — teleport', () => { + it('drops the actor into an arbitrary state + context', () => { + const actor = mockActor( + createDeleteOrgMachine(() => Promise.resolve()), + { + value: 'confirming', + context: { confirmValue: 'Acme Inc' }, + }, + ); + const snapshot = actor.getSnapshot(); + expect(snapshot.value).toBe('confirming'); + // Provided context is merged over the machine defaults. + expect(snapshot.context).toEqual({ name: 'Acme Inc', confirmValue: 'Acme Inc', error: null }); + expect(snapshot.status).toBe('active'); + }); + + it('is inert: teleporting into an invoking state does not fire the invoke', async () => { + const destroyOrg = vi.fn(() => Promise.resolve()); + const actor = mockActor(createDeleteOrgMachine(destroyOrg), { value: 'deleting' }); + + await tick(); + expect(destroyOrg).not.toHaveBeenCalled(); + expect(actor.getSnapshot().value).toBe('deleting'); + }); + + it('reports done status when teleported into a final state', () => { + const actor = mockActor( + createDeleteOrgMachine(() => Promise.resolve()), + { value: 'deleted' }, + ); + expect(actor.getSnapshot().status).toBe('done'); + }); + + it('is still live — can take events from the teleported state', () => { + const actor = mockActor( + createDeleteOrgMachine(() => Promise.resolve()), + { value: 'confirming' }, + ); + actor.send({ type: 'CANCEL' }); + expect(actor.getSnapshot().value).toBe('idle'); + }); +}); + +describe('pure helpers (testable without an actor)', () => { + it('a guard is just a (context, event) predicate', () => { + const nameMatches = (context: DeleteOrgContext) => context.confirmValue === context.name; + expect(nameMatches({ name: 'Acme Inc', confirmValue: 'Acme Inc', error: null })).toBe(true); + expect(nameMatches({ name: 'Acme Inc', confirmValue: 'nope', error: null })).toBe(false); + }); + + it('assign builds a tagged action wrapping a pure updater', () => { + const action = assign((_, event) => + event.type === 'TYPE' ? { confirmValue: event.value } : {}, + ); + expect(isAssignAction(action)).toBe(true); + // The updater itself is pure and unit-testable in isolation. + expect( + action.assignment({ name: 'Acme Inc', confirmValue: '', error: null }, { type: 'TYPE', value: 'hi' }), + ).toEqual({ confirmValue: 'hi' }); + }); + + it('isAssignAction rejects plain side-effect actions', () => { + expect(isAssignAction(() => undefined)).toBe(false); + expect(isAssignAction(null)).toBe(false); + expect(isAssignAction({ type: 'other' })).toBe(false); + }); +}); + +describe('actor restart — StrictMode start/stop/start cycle', () => { + it('send works after stop() + start() (simulates StrictMode effect cleanup)', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + + actor.start(); + expect(actor.getSnapshot().value).toBe('idle'); + + // StrictMode cleanup runs the effect teardown… + actor.stop(); + expect(actor.getSnapshot().status).toBe('stopped'); + + // …then remounts and runs the effect again. + actor.start(); + expect(actor.getSnapshot().status).toBe('active'); + + actor.send({ type: 'OPEN' }); + expect(actor.getSnapshot().value).toBe('confirming'); + }); + + it('does not re-fire an in-flight invoke when restarted from an invoke state', async () => { + const gate = deferred(); + let invokeCount = 0; + const actor = createActor( + createDeleteOrgMachine(() => { + invokeCount++; + return gate.promise; + }), + ); + + actor.start(); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Acme Inc' }); + actor.send({ type: 'CONFIRM' }); + expect(actor.getSnapshot().value).toBe('deleting'); + expect(invokeCount).toBe(1); + + // Stopped mid-invoke (e.g. Suspense remount) then restarted. + actor.stop(); + actor.start(); + expect(actor.getSnapshot().status).toBe('active'); + // The invoke must not fire a second time. + expect(invokeCount).toBe(1); + }); +}); + +describe('createActor — after (delayed transitions)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('auto-advances to the target state after the delay', () => { + const machine = createMachine, { type: 'SUBMIT' }>({ + id: 'otp', + initial: 'codeSent', + context: {}, + states: { + codeSent: { + after: { 60_000: 'expired' }, + on: { SUBMIT: 'verifying' }, + }, + expired: {}, + verifying: {}, + }, + }); + + const actor = createActor(machine); + actor.start(); + expect(actor.getSnapshot().value).toBe('codeSent'); + + vi.advanceTimersByTime(59_999); + expect(actor.getSnapshot().value).toBe('codeSent'); + + vi.advanceTimersByTime(1); + expect(actor.getSnapshot().value).toBe('expired'); + }); + + it('cancels the timer when an explicit event fires first', () => { + const machine = createMachine, { type: 'SUBMIT' }>({ + id: 'otp', + initial: 'codeSent', + context: {}, + states: { + codeSent: { + after: { 60_000: 'expired' }, + on: { SUBMIT: 'verifying' }, + }, + expired: {}, + verifying: {}, + }, + }); + + const actor = createActor(machine); + actor.start(); + + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot().value).toBe('verifying'); + + vi.advanceTimersByTime(60_000); + expect(actor.getSnapshot().value).toBe('verifying'); + }); + + it('cancels the timer on stop()', () => { + const visited: string[] = []; + const machine = createMachine, { type: 'never' }>({ + id: 'otp', + initial: 'codeSent', + context: {}, + states: { + codeSent: { after: { 60_000: 'expired' } }, + expired: {}, + }, + }); + + const actor = createActor(machine); + actor.subscribe(snap => visited.push(snap.value)); + actor.start(); + actor.stop(); + + vi.advanceTimersByTime(60_000); + expect(visited).not.toContain('expired'); + }); + + it('when two after keys exist, the first to fire clears the other', () => { + const machine = createMachine, { type: 'never' }>({ + id: 'race', + initial: 'waiting', + context: {}, + states: { + waiting: { + after: { + 500: 'first', + 1_000: 'second', + }, + }, + first: {}, + second: {}, + }, + }); + + const actor = createActor(machine); + actor.start(); + + vi.advanceTimersByTime(500); + expect(actor.getSnapshot().value).toBe('first'); + + vi.advanceTimersByTime(500); + expect(actor.getSnapshot().value).toBe('first'); // the 1000ms timer was cancelled + }); + + it('passes AfterEvent with the correct delay to transition actions', () => { + let capturedDelay: number | undefined; + const machine = createMachine, { type: 'never' }>({ + id: 'timed', + initial: 'waiting', + context: {}, + states: { + waiting: { + after: { + 500: { + target: 'done', + actions: [ + (_ctx, event) => { + capturedDelay = event.delay; + }, + ], + }, + }, + }, + done: { type: 'final' }, + }, + }); + + const actor = createActor(machine); + actor.start(); + vi.advanceTimersByTime(500); + + expect(capturedDelay).toBe(500); + expect(actor.getSnapshot().value).toBe('done'); + }); + + it('supports a guard on an after transition', () => { + const machine = createMachine<{ allow: boolean }, { type: 'never' }>({ + id: 'guarded', + initial: 'waiting', + context: { allow: false }, + states: { + waiting: { + after: { + 500: { target: 'done', guard: ctx => ctx.allow }, + }, + }, + done: {}, + }, + }); + + const actor = createActor(machine); + actor.start(); + + vi.advanceTimersByTime(500); + expect(actor.getSnapshot().value).toBe('waiting'); // guard blocked it + + // Guards on after transitions are evaluated at fire-time, not schedule-time. + // A failing guard leaves the state unchanged (same as event transitions). + }); + + it('restarts cleanly after stop/start (StrictMode cycle)', () => { + const machine = createMachine, { type: 'never' }>({ + id: 'otp', + initial: 'codeSent', + context: {}, + states: { + codeSent: { after: { 60_000: 'expired' } }, + expired: {}, + }, + }); + + const actor = createActor(machine); + actor.start(); + actor.stop(); // StrictMode cleanup + + actor.start(); // StrictMode remount — must reschedule the timer + vi.advanceTimersByTime(60_000); + expect(actor.getSnapshot().value).toBe('expired'); + }); +}); diff --git a/packages/ui/src/mosaic/machine/__tests__/patterns.test.ts b/packages/ui/src/mosaic/machine/__tests__/patterns.test.ts new file mode 100644 index 00000000000..6c2856c9dc6 --- /dev/null +++ b/packages/ui/src/mosaic/machine/__tests__/patterns.test.ts @@ -0,0 +1,818 @@ +/** + * Real-world pattern tests. + * + * Each describe block proves that a specific Clerk UI coordination pattern + * works with existing library primitives — no new library code required. + * Read these as "here's how to do X" documentation, not just regression tests. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../createActor'; +import { setup } from '../setup'; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ─── Pattern 1: Multiple competing entry points (replaces useLoadingStatus + useCardState) ── + +/** + * A card with social OAuth buttons AND an email submit. Clicking any of them + * should disable the whole card and show a spinner on the clicked control only. + * + * Old approach: `card.setLoading(strategy)` + per-button `status.isLoading` + * Machine approach: `submitting` state + `activeStrategy` in context. + */ +describe('pattern: multi-button loading (social + email)', () => { + type Strategy = 'google' | 'github'; + type Ctx = { activeStrategy: Strategy | 'email' | null; error: string | null }; + type Evt = + | { type: 'CLICK_SOCIAL'; strategy: Strategy } + | { type: 'TYPE_EMAIL'; value: string } + | { type: 'SUBMIT_EMAIL' }; + + const { createMachine, assign, fromPromise } = setup(); + + function makeSignInMachine(socialFn: () => Promise, emailFn: () => Promise) { + return createMachine({ + initial: 'idle', + context: { activeStrategy: null, error: null }, + states: { + idle: { + on: { + CLICK_SOCIAL: { + target: 'submitting', + actions: assign((_, e) => ({ activeStrategy: e.strategy })), + }, + SUBMIT_EMAIL: { + target: 'submitting', + actions: assign(() => ({ activeStrategy: 'email' as const })), + }, + }, + }, + submitting: { + // All entry points converge here. CLICK_SOCIAL while submitting is a no-op — + // `idle`'s handlers are inactive, so simultaneous triggers are impossible. + invoke: fromPromise(ctx => (ctx.activeStrategy === 'email' ? emailFn() : socialFn()), { + onDone: { target: 'success' }, + onError: { + target: 'idle', + actions: assign((_, e) => ({ error: String(e.error), activeStrategy: null })), + }, + }), + }, + success: { type: 'final' }, + }, + }); + } + + it('starts idle with no active strategy', () => { + const actor = createActor( + makeSignInMachine( + () => Promise.resolve(), + () => Promise.resolve(), + ), + ); + actor.start(); + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.activeStrategy).toBeNull(); + }); + + it('enters submitting and records the clicked social strategy', () => { + const gate = deferred(); + const actor = createActor( + makeSignInMachine( + () => gate.promise, + () => Promise.resolve(), + ), + ); + actor.start(); + actor.send({ type: 'CLICK_SOCIAL', strategy: 'google' }); + expect(actor.getSnapshot().value).toBe('submitting'); + expect(actor.getSnapshot().context.activeStrategy).toBe('google'); + }); + + it('records email as active strategy when submitting via email', () => { + const gate = deferred(); + const actor = createActor( + makeSignInMachine( + () => Promise.resolve(), + () => gate.promise, + ), + ); + actor.start(); + actor.send({ type: 'SUBMIT_EMAIL' }); + expect(actor.getSnapshot().context.activeStrategy).toBe('email'); + }); + + it('ignores a second CLICK_SOCIAL while already submitting', () => { + const gate = deferred(); + const actor = createActor( + makeSignInMachine( + () => gate.promise, + () => Promise.resolve(), + ), + ); + actor.start(); + actor.send({ type: 'CLICK_SOCIAL', strategy: 'google' }); + const before = actor.getSnapshot(); + actor.send({ type: 'CLICK_SOCIAL', strategy: 'github' }); // should be ignored + expect(actor.getSnapshot()).toBe(before); + expect(actor.getSnapshot().context.activeStrategy).toBe('google'); + }); + + it('returns to idle with error and clears activeStrategy on failure', async () => { + const actor = createActor( + makeSignInMachine( + () => Promise.reject(new Error('OAuth failed')), + () => Promise.resolve(), + ), + ); + actor.start(); + actor.send({ type: 'CLICK_SOCIAL', strategy: 'github' }); + await tick(); + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.activeStrategy).toBeNull(); + expect(actor.getSnapshot().context.error).toBe('Error: OAuth failed'); + }); + + it('reaches success (final) when the async call resolves', async () => { + const actor = createActor( + makeSignInMachine( + () => Promise.resolve(), + () => Promise.resolve(), + ), + ); + actor.start(); + actor.send({ type: 'CLICK_SOCIAL', strategy: 'google' }); + await tick(); + expect(actor.getSnapshot().value).toBe('success'); + expect(actor.getSnapshot().status).toBe('done'); + }); +}); + +// ─── Pattern 2: Resend + cooldown (replaces TimerButton + useLoadingStatus) ─── + +/** + * A "resend code" button that: + * 1. Calls an API to re-send the code + * 2. Disables itself for 30 seconds (cooldown) + * 3. Re-enables after the cooldown + * + * Old approach: `setInterval` inside a `useEffect`, with `useLoadingStatus`. + * Machine approach: `resending` (invoke) → `cooldown` (after 30s) → `verifying`. + * The timer is managed by the machine; no useEffect or setInterval in the component. + */ +describe('pattern: resend + cooldown timer', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + type Ctx = { canResend: boolean; error: string | null }; + type Evt = { type: 'SUBMIT' } | { type: 'RESEND' }; + + const { createMachine, assign, fromPromise } = setup(); + + function makeMachine(resendFn: () => Promise) { + return createMachine({ + initial: 'verifying', + context: { canResend: true, error: null }, + states: { + verifying: { + on: { + SUBMIT: 'submitting', + RESEND: 'resending', + }, + }, + submitting: { type: 'final' }, + resending: { + invoke: fromPromise(() => resendFn(), { + onDone: { + target: 'cooldown', + actions: assign(() => ({ canResend: false, error: null })), + }, + onError: { + target: 'verifying', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + cooldown: { + after: { + 30_000: { + target: 'verifying', + actions: assign(() => ({ canResend: true })), + }, + }, + }, + }, + }); + } + + it('starts in verifying with canResend=true', () => { + const actor = createActor(makeMachine(() => Promise.resolve())); + actor.start(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.canResend).toBe(true); + }); + + it('enters resending on RESEND', () => { + const gate = deferred(); + const actor = createActor(makeMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'RESEND' }); + expect(actor.getSnapshot().value).toBe('resending'); + }); + + it('enters cooldown after resend resolves and sets canResend=false', async () => { + const actor = createActor(makeMachine(() => Promise.resolve())); + actor.start(); + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); + expect(actor.getSnapshot().value).toBe('cooldown'); + expect(actor.getSnapshot().context.canResend).toBe(false); + }); + + it('returns to verifying with canResend=true after 30s', async () => { + const actor = createActor(makeMachine(() => Promise.resolve())); + actor.start(); + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); + vi.advanceTimersByTime(30_000); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.canResend).toBe(true); + }); + + it('cannot RESEND during cooldown — RESEND is not handled in cooldown state', async () => { + const actor = createActor(makeMachine(() => Promise.resolve())); + actor.start(); + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); // → cooldown + const before = actor.getSnapshot(); + actor.send({ type: 'RESEND' }); // cooldown has no RESEND handler → no-op + expect(actor.getSnapshot()).toBe(before); + }); + + it('returns to verifying with error if resend fails — no cooldown started', async () => { + const actor = createActor(makeMachine(() => Promise.reject(new Error('Rate limited')))); + actor.start(); + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.error).toBe('Error: Rate limited'); + }); +}); + +// ─── Pattern 3: Error cleared on new input ──────────────────────────────────── + +/** + * An error set by a failed submission should clear as soon as the user types. + * Old approach: `card.setError(undefined)` in the onChange handler. + * Machine approach: `assign` inside the `TYPE` handler clears `error`. + */ +describe('pattern: error cleared on new input', () => { + type Ctx = { value: string; error: string | null }; + type Evt = { type: 'TYPE'; value: string } | { type: 'SUBMIT' }; + + const { createMachine, assign, fromPromise } = setup(); + + function makeMachine(submitFn: (value: string) => Promise) { + return createMachine({ + initial: 'idle', + context: { value: '', error: null }, + states: { + idle: { + on: { + // assign clears error atomically with updating the value — no extra + // `card.setError(undefined)` call required + TYPE: { actions: assign((_, e) => ({ value: e.value, error: null })) }, + SUBMIT: 'submitting', + }, + }, + submitting: { + invoke: fromPromise(ctx => submitFn(ctx.value), { + onDone: 'success', + onError: { + target: 'idle', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + success: { type: 'final' }, + }, + }); + } + + it('error is set on failed submit', async () => { + const actor = createActor(makeMachine(() => Promise.reject(new Error('Invalid')))); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().context.error).toBe('Error: Invalid'); + }); + + it('error is cleared immediately when user types after a failed submit', async () => { + const actor = createActor(makeMachine(() => Promise.reject(new Error('Invalid')))); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + actor.send({ type: 'TYPE', value: 'corrected' }); + expect(actor.getSnapshot().context.error).toBeNull(); + expect(actor.getSnapshot().context.value).toBe('corrected'); + }); +}); + +// ─── Pattern 4: Conditional initial state + always routing ─────────────────── + +/** + * A factor verification screen where: + * - Code-based factors (email_code, phone_code) call prepareFirstFactor on entry + * - Password goes straight to the input field + * - Switching strategies routes back through the same logic + * + * Old approach: `if (needsPrepare) { prepare() }` in a useEffect. + * Machine approach: `initial` as a function + `always` transient state. + */ +describe('pattern: conditional initial state + always routing on strategy switch', () => { + const CODE_STRATEGIES = new Set(['email_code', 'phone_code']); + const needsPrepare = (strategy: string) => CODE_STRATEGIES.has(strategy); + + type Ctx = { strategy: string; value: string; error: string | null }; + type Evt = { type: 'TYPE'; value: string } | { type: 'SUBMIT' } | { type: 'SELECT'; strategy: string }; + + const { createMachine, assign, fromPromise } = setup(); + + function makeMachine(prepareFn: () => Promise) { + return createMachine({ + initial: ctx => (needsPrepare(ctx.strategy) ? 'preparing' : 'verifying'), + context: { strategy: 'password', value: '', error: null }, + states: { + preparing: { + // SELECT is handled here too so the user can switch strategy while + // the current prepare is still in-flight. The library cancels the + // invoke automatically when the transition fires (invocationToken bump). + on: { + SELECT: { + target: 'routing', + actions: assign((_, e) => ({ strategy: e.strategy, value: '', error: null })), + }, + }, + invoke: fromPromise(() => prepareFn(), { + onDone: 'verifying', + onError: { + target: 'verifying', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + verifying: { + on: { + TYPE: { actions: assign((_, e) => ({ value: e.value, error: null })) }, + SUBMIT: 'submitting', + SELECT: { + target: 'routing', + actions: assign((_, e) => ({ strategy: e.strategy, value: '', error: null })), + }, + }, + }, + routing: { + // Transient: immediately routes to preparing or verifying based on new strategy. + always: [{ target: 'preparing', guard: ctx => needsPrepare(ctx.strategy) }, { target: 'verifying' }], + }, + submitting: { type: 'final' }, + }, + }); + } + + it('starts in verifying for password strategy', () => { + const actor = createActor( + makeMachine(() => Promise.resolve()), + { + context: { strategy: 'password' }, + }, + ); + actor.start(); + expect(actor.getSnapshot().value).toBe('verifying'); + }); + + it('starts in preparing for email_code strategy', () => { + const gate = deferred(); + const actor = createActor( + makeMachine(() => gate.promise), + { + context: { strategy: 'email_code' }, + }, + ); + actor.start(); + expect(actor.getSnapshot().value).toBe('preparing'); + }); + + it('transitions from preparing to verifying once prepare resolves', async () => { + const actor = createActor( + makeMachine(() => Promise.resolve()), + { + context: { strategy: 'email_code' }, + }, + ); + actor.start(); + await tick(); + expect(actor.getSnapshot().value).toBe('verifying'); + }); + + it('routes to preparing when switching to a code strategy', () => { + const gate = deferred(); + const actor = createActor(makeMachine(() => gate.promise)); + actor.start(); // starts in verifying (password) + actor.send({ type: 'SELECT', strategy: 'phone_code' }); + expect(actor.getSnapshot().value).toBe('preparing'); + expect(actor.getSnapshot().context.strategy).toBe('phone_code'); + }); + + it('routes to verifying when switching to password strategy', () => { + const gate = deferred(); + const actor = createActor( + makeMachine(() => gate.promise), + { + context: { strategy: 'email_code' }, + }, + ); + actor.start(); // starts in preparing + // Simulate switching to password while prepare is in-flight + actor.send({ type: 'SELECT', strategy: 'password' }); + expect(actor.getSnapshot().value).toBe('verifying'); + }); +}); + +// ─── Pattern 5: Post-invoke routing via always transient state ──────────────── + +/** + * After an async call resolves, route to one of several states based on the + * returned status string. The status is stored in context; an `always` transient + * state fans out immediately. + * + * Old approach: `if (status === 'x') { wizard.goto('x') }` chains in then/catch. + * Machine approach: `onDone` stores the status; `always` reads it from context. + */ +describe('pattern: post-invoke routing via always transient state', () => { + type Ctx = { pendingStatus: string }; + type Evt = { type: 'START' }; + + const { createMachine, assign, fromPromise } = setup(); + + function makeMachine(asyncFn: () => Promise<{ status: string }>) { + return createMachine({ + initial: 'idle', + context: { pendingStatus: '' }, + states: { + idle: { on: { START: 'loading' } }, + loading: { + invoke: fromPromise(() => asyncFn(), { + onDone: { + target: 'routing', + actions: assign((_, e) => ({ pendingStatus: e.output.status })), + }, + onError: { target: 'idle' }, + }), + }, + routing: { + always: [ + { target: 'firstFactor', guard: ctx => ctx.pendingStatus === 'needs_first_factor' }, + { target: 'secondFactor', guard: ctx => ctx.pendingStatus === 'needs_second_factor' }, + { target: 'complete' }, + ], + }, + firstFactor: {}, + secondFactor: {}, + complete: { type: 'final' }, + }, + }); + } + + it('routes to firstFactor when status is needs_first_factor', async () => { + const actor = createActor(makeMachine(() => Promise.resolve({ status: 'needs_first_factor' }))); + actor.start(); + actor.send({ type: 'START' }); + await tick(); + expect(actor.getSnapshot().value).toBe('firstFactor'); + }); + + it('routes to secondFactor when status is needs_second_factor', async () => { + const actor = createActor(makeMachine(() => Promise.resolve({ status: 'needs_second_factor' }))); + actor.start(); + actor.send({ type: 'START' }); + await tick(); + expect(actor.getSnapshot().value).toBe('secondFactor'); + }); + + it('routes to complete as the fallback when no guard matches', async () => { + const actor = createActor(makeMachine(() => Promise.resolve({ status: 'complete' }))); + actor.start(); + actor.send({ type: 'START' }); + await tick(); + expect(actor.getSnapshot().value).toBe('complete'); + expect(actor.getSnapshot().status).toBe('done'); + }); +}); + +// ─── Pattern 6: Typed invoke output via fromPromise ─────────────────────────── + +/** + * `fromPromise` carries the resolved type to `onDone.actions`, so the full + * resource is available without `.status`-extraction workarounds in `src`. + * + * Before: + * src: async ctx => (await ctx.fetchFn()).status // only string, full object lost + * After: + * fromPromise(ctx => ctx.fetchFn(), { onDone: { actions: assign((_, e) => ({ status: e.output.status })) } }) + * // e.output is the full resource — correct type, access any field + */ +describe('pattern: typed invoke output via fromPromise', () => { + interface Resource { + status: string; + id: string; + } + + type Ctx = { resource: Resource | null; error: string | null; fetchFn: () => Promise }; + type Evt = { type: 'FETCH' }; + + const { createMachine, assign, fromPromise } = setup(); + + const machine = createMachine({ + initial: 'idle', + context: { resource: null, error: null, fetchFn: async () => ({ status: '', id: '' }) }, + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { + invoke: fromPromise(ctx => ctx.fetchFn(), { + onDone: { + target: 'done', + // e.output is Resource — both fields accessible without a cast + actions: assign((_, e) => ({ resource: e.output })), + }, + onError: { + target: 'idle', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + done: { type: 'final' }, + }, + }); + + it('stores the full resolved resource in context', async () => { + const resource: Resource = { status: 'complete', id: 'res_123' }; + const actor = createActor(machine, { context: { fetchFn: async () => resource } }); + actor.start(); + actor.send({ type: 'FETCH' }); + await tick(); + expect(actor.getSnapshot().context.resource).toEqual(resource); + expect(actor.getSnapshot().context.resource?.id).toBe('res_123'); + }); + + it('stores the full resource including non-status fields', async () => { + const resource: Resource = { status: 'needs_first_factor', id: 'si_abc' }; + const actor = createActor(machine, { context: { fetchFn: async () => resource } }); + actor.start(); + actor.send({ type: 'FETCH' }); + await tick(); + // The full object is available — not just the primitive we had to extract before + expect(actor.getSnapshot().context.resource?.status).toBe('needs_first_factor'); + expect(actor.getSnapshot().context.resource?.id).toBe('si_abc'); + }); +}); + +// ─── Pattern 7: On-mount async (replaces useEffect(fn, [])) ────────────────── + +/** + * A component that must perform async work immediately on mount and route + * based on the result. The classic implementation is: + * + * useEffect(() => { + * signIn.create({ strategy: 'ticket', ticket }).then(res => { + * if (res.status === 'needs_first_factor') navigate('factor-one'); + * ... + * }); + * }, []); + * + * Machine approach: make the async work the INITIAL state. actor.start() fires + * the invoke — no useEffect in the component, no imperative navigate calls. + * + * The component renders a loading indicator while in `redeeming`, and never + * needs to know what "needs_first_factor" means — it just reads snapshot.value. + */ +describe('pattern: on-mount async — initial state invokes immediately', () => { + type Ctx = { ticket: string; pendingStatus: string }; + type Evt = { type: 'CANCEL' }; + + const { createMachine, assign, fromPromise } = setup(); + + function makeMachine(redeemFn: (ticket: string) => Promise<{ status: string }>) { + return createMachine({ + initial: 'redeeming', // fires on actor.start() — no useEffect needed + context: { ticket: '', pendingStatus: '' }, + states: { + redeeming: { + invoke: fromPromise(ctx => redeemFn(ctx.ticket), { + onDone: { + target: 'routing', + actions: assign((_, e) => ({ pendingStatus: e.output.status })), + }, + onError: { target: 'failed' }, + }), + on: { CANCEL: 'cancelled' }, + }, + routing: { + always: [ + { target: 'firstFactor', guard: ctx => ctx.pendingStatus === 'needs_first_factor' }, + { target: 'secondFactor', guard: ctx => ctx.pendingStatus === 'needs_second_factor' }, + { target: 'complete' }, + ], + }, + firstFactor: {}, + secondFactor: {}, + complete: { type: 'final' }, + failed: {}, + cancelled: {}, + }, + }); + } + + it('fires the async call immediately on start — no useEffect in the component', async () => { + const redeemFn = vi.fn(() => Promise.resolve({ status: 'needs_first_factor' })); + const actor = createActor(makeMachine(redeemFn), { context: { ticket: 'org_ticket_123' } }); + actor.start(); + expect(actor.getSnapshot().value).toBe('redeeming'); + expect(redeemFn).toHaveBeenCalledWith('org_ticket_123'); + }); + + it('routes to firstFactor when the ticket resolves to needs_first_factor', async () => { + const actor = createActor( + makeMachine(() => Promise.resolve({ status: 'needs_first_factor' })), + { context: { ticket: 'tk' } }, + ); + actor.start(); + await tick(); + expect(actor.getSnapshot().value).toBe('firstFactor'); + }); + + it('routes to secondFactor when the ticket resolves to needs_second_factor', async () => { + const actor = createActor( + makeMachine(() => Promise.resolve({ status: 'needs_second_factor' })), + { context: { ticket: 'tk' } }, + ); + actor.start(); + await tick(); + expect(actor.getSnapshot().value).toBe('secondFactor'); + }); + + it('routes to complete as the fallback', async () => { + const actor = createActor( + makeMachine(() => Promise.resolve({ status: 'complete' })), + { context: { ticket: 'tk' } }, + ); + actor.start(); + await tick(); + expect(actor.getSnapshot().value).toBe('complete'); + expect(actor.getSnapshot().status).toBe('done'); + }); + + it('transitions to failed on error', async () => { + const actor = createActor( + makeMachine(() => Promise.reject(new Error('Invalid ticket'))), + { context: { ticket: 'bad' } }, + ); + actor.start(); + await tick(); + expect(actor.getSnapshot().value).toBe('failed'); + }); + + it('can be cancelled while the invoke is in-flight — late resolve is a no-op', async () => { + const gate = deferred<{ status: string }>(); + const actor = createActor( + makeMachine(() => gate.promise), + { context: { ticket: 'tk' } }, + ); + actor.start(); + actor.send({ type: 'CANCEL' }); + expect(actor.getSnapshot().value).toBe('cancelled'); + gate.resolve({ status: 'complete' }); + await tick(); + // Machine stays in cancelled — the in-flight invoke was abandoned + expect(actor.getSnapshot().value).toBe('cancelled'); + }); +}); + +// ─── Pattern 8: Reactive value → auto-switch (replaces useLayoutEffect) ─────── + +/** + * A sign-in identifier field that auto-switches to phone input when the user + * types (or a browser autofills) a value starting with "+". + * + * Old approach: + * const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false); + * useLayoutEffect(() => { + * if (value.startsWith('+') && phoneEnabled && fieldType !== 'phone' && !hasSwitchedByAutofill) { + * switchToPhone(value); + * setHasSwitchedByAutofill(true); // prevent re-triggering on subsequent autofills + * } + * }, [value]); + * + * Machine approach: the TYPE event handler applies the same guard atomically. + * `hasAutoSwitched` lives in context alongside the value — no separate useState, + * no useLayoutEffect, no risk of the effect firing after unmount. + * + * The loop-prevention guard (hasAutoSwitched) works the same way; a manual + * SWITCH_FIELD event resets it so autofill can trigger once more. + */ +describe('pattern: reactive value auto-switch (replaces useLayoutEffect + hasSwitchedByAutofill)', () => { + type FieldType = 'text' | 'phone'; + type Ctx = { value: string; fieldType: FieldType; hasAutoSwitched: boolean }; + type Evt = { type: 'TYPE'; value: string } | { type: 'SWITCH_FIELD' } | { type: 'SUBMIT' }; + + const { createMachine, assign } = setup(); + + function makeMachine(phoneEnabled: boolean) { + return createMachine({ + initial: 'idle', + context: { value: '', fieldType: 'text', hasAutoSwitched: false }, + states: { + idle: { + on: { + TYPE: { + // All the useLayoutEffect logic lives here — atomically with the value update. + actions: assign((ctx, e) => { + const shouldSwitch = + phoneEnabled && e.value.startsWith('+') && ctx.fieldType !== 'phone' && !ctx.hasAutoSwitched; + if (shouldSwitch) { + return { value: e.value, fieldType: 'phone' as FieldType, hasAutoSwitched: true }; + } + return { value: e.value }; + }), + }, + SWITCH_FIELD: { + // Manual switch resets the auto-switch guard so autofill can trigger once more. + actions: assign(ctx => ({ + fieldType: (ctx.fieldType === 'text' ? 'phone' : 'text') as FieldType, + hasAutoSwitched: false, + })), + }, + SUBMIT: 'submitting', + }, + }, + submitting: { type: 'final' }, + }, + }); + } + + it('switches to phone when value starts with +', () => { + const actor = createActor(makeMachine(true)); + actor.start(); + actor.send({ type: 'TYPE', value: '+1 555 123 4567' }); + expect(actor.getSnapshot().context.fieldType).toBe('phone'); + expect(actor.getSnapshot().context.hasAutoSwitched).toBe(true); + }); + + it('does not switch again on subsequent autofills — loop prevention', () => { + const actor = createActor(makeMachine(true)); + actor.start(); + actor.send({ type: 'TYPE', value: '+1 555 123 4567' }); // auto-switches, marks guard + actor.send({ type: 'TYPE', value: '+1 999 000 0000' }); // should NOT switch again + expect(actor.getSnapshot().context.fieldType).toBe('phone'); + expect(actor.getSnapshot().context.value).toBe('+1 999 000 0000'); // value still updates + }); + + it('does not switch when phone is not enabled', () => { + const actor = createActor(makeMachine(false)); + actor.start(); + actor.send({ type: 'TYPE', value: '+44 20 1234 5678' }); + expect(actor.getSnapshot().context.fieldType).toBe('text'); + }); + + it('manual SWITCH_FIELD resets the guard so autofill can trigger once more', () => { + const actor = createActor(makeMachine(true)); + actor.start(); + actor.send({ type: 'TYPE', value: '+1 555' }); // auto-switches to phone, hasAutoSwitched = true + actor.send({ type: 'SWITCH_FIELD' }); // user manually switches back to text, resets guard + expect(actor.getSnapshot().context.fieldType).toBe('text'); + expect(actor.getSnapshot().context.hasAutoSwitched).toBe(false); + actor.send({ type: 'TYPE', value: '+44 777' }); // autofill can trigger once more + expect(actor.getSnapshot().context.fieldType).toBe('phone'); + }); + + it('does not switch when already in phone mode', () => { + const actor = createActor(makeMachine(true)); + actor.start(); + actor.send({ type: 'SWITCH_FIELD' }); // manually switch to phone + const before = actor.getSnapshot(); + actor.send({ type: 'TYPE', value: '+1 555' }); // already phone — no change to fieldType + expect(actor.getSnapshot().context.fieldType).toBe('phone'); + expect(actor.getSnapshot().context.hasAutoSwitched).toBe(before.context.hasAutoSwitched); + }); +}); diff --git a/packages/ui/src/mosaic/machine/__tests__/useMachine.test.tsx b/packages/ui/src/mosaic/machine/__tests__/useMachine.test.tsx new file mode 100644 index 00000000000..dd125d1bd3d --- /dev/null +++ b/packages/ui/src/mosaic/machine/__tests__/useMachine.test.tsx @@ -0,0 +1,314 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { assign } from '../assign'; +import { createActor, mockActor } from '../createActor'; +import { createMachine } from '../createMachine'; +import { useActor, useMachine, useSelector } from '../useMachine'; +import { createDeleteOrgMachine } from './delete-organization-machine'; + +/** A promise whose resolution the test controls. */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +} + +describe('useMachine — drives a flow from a component', () => { + it('re-renders as send advances the machine through its states', async () => { + const gate = deferred(); + + function DeleteOrg() { + const [snapshot, send] = useMachine(createDeleteOrgMachine(() => gate.promise)); + return ( +
+ {snapshot.value} + + + +
+ ); + } + + render(); + expect(screen.getByTestId('state')).toHaveTextContent('idle'); + + fireEvent.click(screen.getByText('Open')); + expect(screen.getByTestId('state')).toHaveTextContent('confirming'); + + // Guard blocks confirm until the name is typed. + fireEvent.click(screen.getByText('Confirm')); + expect(screen.getByTestId('state')).toHaveTextContent('confirming'); + + fireEvent.click(screen.getByText('Type name')); + fireEvent.click(screen.getByText('Confirm')); + expect(screen.getByTestId('state')).toHaveTextContent('deleting'); + + await act(async () => { + gate.resolve(); + }); + expect(screen.getByTestId('state')).toHaveTextContent('deleted'); + }); +}); + +describe('useMachine — context init option', () => { + it('passes runtime context through to the actor', () => { + type Ctx = { label: string }; + const machine = createMachine({ + initial: 'idle', + context: { label: 'default' }, + states: { idle: {} }, + }); + + function Comp() { + const [snapshot] = useMachine(machine, { context: { label: 'runtime' } }); + return {snapshot.context.label}; + } + + render(); + expect(screen.getByText('runtime')).toBeInTheDocument(); + }); +}); + +describe('useActor + mockActor — render a teleported step', () => { + it('shows the content for a state reached only by teleport', () => { + // `deleting` sits behind a 2s mutation — unreachable by clicking. Teleport in. + const actor = mockActor( + createDeleteOrgMachine(() => Promise.resolve()), + { + value: 'deleting', + context: { name: 'Acme Inc', confirmValue: 'Acme Inc' }, + }, + ); + + function Step() { + const [snapshot] = useActor(actor); + return
{snapshot.value === 'deleting' ? 'Deleting…' : `state: ${snapshot.value}`}
; + } + + render(); + expect(screen.getByText('Deleting…')).toBeInTheDocument(); + }); +}); + +describe('useSelector — re-renders only on the selected slice', () => { + type Ctx = { a: number; b: number }; + type Ev = { type: 'INC_A' } | { type: 'INC_B' }; + + const makeMachine = () => + createMachine({ + initial: 'active', + context: { a: 0, b: 0 }, + states: { + active: { + on: { + INC_A: { actions: assign(c => ({ a: c.a + 1 })) }, + INC_B: { actions: assign(c => ({ b: c.b + 1 })) }, + }, + }, + }, + }); + + it('does not re-render when an unrelated slice changes', () => { + const actor = createActor(makeMachine()); + actor.start(); + + const renders = vi.fn(); + function ReadsA() { + const a = useSelector(actor, s => s.context.a); + renders(); + return {a}; + } + + render(); + expect(renders).toHaveBeenCalledTimes(1); + + // Unrelated change → selected slice (`a`) is unchanged → no re-render. + act(() => actor.send({ type: 'INC_B' })); + expect(renders).toHaveBeenCalledTimes(1); + + // Selected change → re-render. + act(() => actor.send({ type: 'INC_A' })); + expect(renders).toHaveBeenCalledTimes(2); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('honors a custom equality function', () => { + const actor = createActor(makeMachine()); + actor.start(); + + const renders = vi.fn(); + function ReadsA() { + // Equality always true → treat every selection as equal → never re-renders. + const a = useSelector( + actor, + s => s.context.a, + () => true, + ); + renders(); + return {a}; + } + + render(); + expect(renders).toHaveBeenCalledTimes(1); + + act(() => actor.send({ type: 'INC_A' })); + act(() => actor.send({ type: 'INC_A' })); + expect(renders).toHaveBeenCalledTimes(1); + // Still shows the stale (first) value because the equality fn suppressed updates. + expect(screen.getByText('0')).toBeInTheDocument(); + }); +}); + +describe('subscription cleanup', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('unsubscribes from the actor on unmount', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + + // Wrap subscribe to count live subscriptions. + let live = 0; + const realSubscribe = actor.subscribe; + actor.subscribe = listener => { + live++; + const unsubscribe = realSubscribe(listener); + return () => { + live--; + unsubscribe(); + }; + }; + + function Reader() { + const [snapshot] = useActor(actor); + return {snapshot.value}; + } + + const { unmount } = render(); + expect(live).toBe(1); + + unmount(); + expect(live).toBe(0); + }); +}); + +describe('useMachine — onDone', () => { + it('calls onDone exactly once when the machine reaches a final state', async () => { + const gate = deferred(); + const onDone = vi.fn(); + + type Ctx = { fn: () => Promise }; + type Ev = { type: 'GO' }; + const machine = createMachine({ + initial: 'idle', + context: { fn: async () => {} }, + states: { + idle: { on: { GO: 'running' } }, + running: { invoke: { src: (ctx: Ctx) => ctx.fn(), onDone: 'done', onError: 'done' } }, + done: { type: 'final' }, + }, + }); + + function Comp() { + const [snapshot, send] = useMachine(machine, { + context: { fn: () => gate.promise }, + onDone, + }); + return ( +
+ {snapshot.value} + +
+ ); + } + + render(); + expect(onDone).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('Go')); + await act(async () => gate.resolve()); + + expect(screen.getByTestId('state')).toHaveTextContent('done'); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('always invokes the latest onDone prop, not a stale closure', async () => { + const gate = deferred(); + const staleCallback = vi.fn(); + const freshCallback = vi.fn(); + + type Ctx = { fn: () => Promise }; + type Ev = { type: 'GO' }; + const machine = createMachine({ + initial: 'idle', + context: { fn: async () => {} }, + states: { + idle: { on: { GO: 'running' } }, + running: { invoke: { src: (ctx: Ctx) => ctx.fn(), onDone: 'done', onError: 'done' } }, + done: { type: 'final' }, + }, + }); + + function Comp({ onDone }: { onDone: () => void }) { + const [, send] = useMachine(machine, { context: { fn: () => gate.promise }, onDone }); + return ; + } + + const { rerender } = render(); + rerender(); + + fireEvent.click(screen.getByText('Go')); + await act(async () => gate.resolve()); + + expect(freshCallback).toHaveBeenCalledTimes(1); + expect(staleCallback).not.toHaveBeenCalled(); + }); +}); + +describe('useMachine — live context keeps injected functions current', () => { + it('invokes the latest fn from options.context even when the prop changes between renders', async () => { + const gate = deferred(); + const staleFn = vi.fn(() => gate.promise); + const freshFn = vi.fn(() => gate.promise); + + type Ctx = { fn: () => Promise }; + type Ev = { type: 'GO' }; + const machine = createMachine({ + initial: 'idle', + context: { fn: async () => {} }, + states: { + idle: { on: { GO: 'running' } }, + running: { invoke: { src: (ctx: Ctx) => ctx.fn(), onDone: 'done', onError: 'done' } }, + done: { type: 'final' }, + }, + }); + + function Runner({ run }: { run: () => Promise }) { + // No refs — options.context is synced into the actor on every render. + const [snapshot, send] = useMachine(machine, { context: { fn: run } }); + return ( +
+ {snapshot.value} + +
+ ); + } + + const { rerender } = render(); + rerender(); + + fireEvent.click(screen.getByText('Go')); + expect(screen.getByTestId('state')).toHaveTextContent('running'); + + await act(async () => gate.resolve()); + expect(screen.getByTestId('state')).toHaveTextContent('done'); + + expect(freshFn).toHaveBeenCalledTimes(1); + expect(staleFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/machine/__tests__/wizard-migration.test.tsx b/packages/ui/src/mosaic/machine/__tests__/wizard-migration.test.tsx new file mode 100644 index 00000000000..06286b1081e --- /dev/null +++ b/packages/ui/src/mosaic/machine/__tests__/wizard-migration.test.tsx @@ -0,0 +1,527 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { assign } from '../assign'; +import { createActor, mockActor } from '../createActor'; +import { createMachine } from '../createMachine'; +import type { StateConfig, StateMachine } from '../types'; +import { useActor, useMachine, useSelector } from '../useMachine'; + +/** + * Wizard migration: before → after. + * + * This suite doubles as the migration guide for porting a hand-rolled reducer + * flow onto the Mosaic state-machine library. It re-expresses the ConfigureSSO + * Wizard — today a pure `reduce(state, event, config)` reducer plus a React seam + * — as a `createMachine` + `createActor` definition, and asserts equivalence with + * every behavior pinned in + * `components/ConfigureSSO/elements/Wizard/__tests__/reducer.test.ts`. + * + * ── BEFORE (the shape that exists today; see the live files, not copied here) ── + * + * `ConfigureSSO/elements/Wizard/reducer.ts` + * - state: `{ current, direction, hasNavigated }` + * - events: `NEXT | PREV | GOTO` (a hand-written discriminated union) + * - guards: `guardHolds(step)` — each step carries an inline `() => boolean` + * entry precondition; an omitted guard defaults TRUE. + * - init: `initialState(config)` walks forward while the *next* step's guard + * holds → the furthest contiguously-reachable step. + * - no-op: EVERY blocked / out-of-bounds path returns the IDENTICAL `state` + * ref (`=== state`) — load-bearing for React's bail-out and for the + * seam's terminal/first "bubble to parent" detection. + * + * `ConfigureSSO/elements/Wizard/useWizardMachine.ts` (the React seam) + * - mirrors `state`/`config` into refs during render so the stable handlers see + * fresh values; + * - a render-phase **re-seat**: if the active step's entry guard stops holding, + * jump to the furthest-reachable step; + * - a render-phase **deferred advance** (`pendingNextFrom`): a parked `NEXT` + * that resolves once an awaited mutation's revalidation makes the next guard + * hold. + * + * ── AFTER (this file) ────────────────────────────────────────────────────────── + * + * `createWizardMachine(descriptors)` builds the same flow as an introspectable + * machine. The reducer's three concepts map one-to-one: + * - `snapshot.value` ↔ `state.current` + * - `snapshot.context` ↔ `{ direction, hasNavigated }` + * - state-node entry `guard` ↔ `guardHolds` (gates every transition landing + * on the step, checked uniformly by the actor) + * - derived `initial` ↔ `initialState` furthest-reachable derivation + * - guard-blocked transition ↔ `takeTransition` returns false → no commit, so + * returns false (no commit) `getSnapshot()` is referentially unchanged AND + * subscribers are not notified (the reducer's + * `=== state` no-op, for free) + * + * The seam's render-phase boilerplate collapses: + * - **re-seat** becomes `actor.recheck()` (re-validates the current entry guard + * and re-seats via the initial resolver — demonstrated below); + * - **deferred advance** disappears: it only existed to paper over React's + * render-phase staleness (a stale `configRef` within one tick). An actor + * holds live state OUTSIDE React, so `send` reads live guards synchronously — + * the "click twice" race never arises. The caller `await`s then `send`s NEXT + * (demonstrated below). Parent/child bubbling is left to a follow-up; the + * boundary signal it would hook (terminal NEXT = same-ref no-op) is asserted. + */ + +interface StepDescriptor { + id: string; + /** Inline entry precondition. Omitted ⇒ always enterable (the entry step). */ + guard?: () => boolean; +} + +interface WizardContext { + /** `1` forward, `-1` back, `0` jump/initial. Mirrors the reducer's field. */ + direction: 1 | -1 | 0; + /** Latched true on the first navigation; backs `isInitialStep`. */ + hasNavigated: boolean; +} + +type WizardEvent = { type: 'NEXT' } | { type: 'PREV' } | { type: 'GOTO'; step: string }; + +const guardHolds = (d: StepDescriptor): boolean => (d.guard ? d.guard() : true); + +/** + * Build the wizard flow from a runtime `descriptors[]` array (a data-driven graph + * — the states are not a static literal). The result is a fully introspectable + * machine: `Object.keys(machine.states)` enumerates the steps in declaration + * order, and each state node carries its entry guard. + */ +function createWizardMachine(descriptors: StepDescriptor[]): StateMachine { + const ids = descriptors.map(d => d.id); + + // Furthest contiguously-reachable step (mirrors reducer.ts `initialState`): + // walk forward while the NEXT step's guard holds, stop at the first gate. + const furthestReachable = (): string => { + if (descriptors.length === 0) return ''; + let i = 0; + while (i + 1 < descriptors.length && guardHolds(descriptors[i + 1])) i++; + return descriptors[i].id; + }; + + const navigated = (direction: 1 | -1 | 0) => + assign(() => ({ direction, hasNavigated: true })); + + const states: Record> = {}; + descriptors.forEach((d, i) => { + const nextId = ids[i + 1]; + const prevId = ids[i - 1]; + states[d.id] = { + // STATE entry guard — gates EVERY transition that targets this step (NEXT + // from the predecessor, PREV from the successor, any GOTO). When it fails + // the transition is a true no-op (same snapshot ref, no notify). + guard: d.guard, + on: { + // One slot forward / back only — positional, never a skip-satisfied walk. + // No handler at the boundary (terminal NEXT / first PREV) ⇒ ignored event + // ⇒ same-ref no-op, the exact signal a parent link would bubble on. + ...(nextId ? { NEXT: { target: nextId, actions: navigated(1) } } : {}), + ...(prevId ? { PREV: { target: prevId, actions: navigated(-1) } } : {}), + // One GOTO candidate per OTHER step; the first whose transition guard + // matches `event.step` wins, then the target's entry guard gates landing. + // Excluding self makes GOTO-to-current match nothing → no-op. + GOTO: descriptors + .filter(t => t.id !== d.id) + .map(t => ({ + target: t.id, + guard: (_ctx: WizardContext, e: WizardEvent) => e.type === 'GOTO' && e.step === t.id, + actions: navigated(0), + })), + }, + }; + }); + + return createMachine({ + id: 'wizard', + initial: furthestReachable, + context: { direction: 0, hasNavigated: false }, + states, + }); +} + +/** + * The stepper view (`isCompleted` positional / `isReachable` guard-driven), + * derived purely by INTROSPECTION — no running instance, just `machine.states` + + * the current step id. This is the swingset seam: docs can enumerate and render + * every step without clicking through the flow. + */ +const deriveStepper = (machine: StateMachine, current: string) => { + const ids = Object.keys(machine.states); + const currentIndex = ids.indexOf(current); + return ids.map((id, i) => { + const guard = machine.states[id].guard; + return { + id, + isCompleted: currentIndex >= 0 && i < currentIndex, + // Same predicate the reducer's `guardHolds` / the stepper's `isReachable` + // bind to. The guards are nullary closures, so the args are inert. + isReachable: guard ? guard({ direction: 0, hasNavigated: false }, { type: 'NEXT' }) : true, + }; + }); +}; + +/** A representative monotonic guard set over 4 steps — mirrors reducer.test.ts. */ +const monotonic = (g1: boolean, g2: boolean, g3: boolean): StepDescriptor[] => [ + { id: 'a' }, + { id: 'b', guard: () => g1 }, + { id: 'c', guard: () => g2 }, + { id: 'd', guard: () => g3 }, +]; + +/** Start an actor teleported to `value` so a parity test can assert from any step. */ +const actorAt = (descriptors: StepDescriptor[], value: string) => { + const actor = createActor(createWizardMachine(descriptors), { snapshot: { value } }); + return actor; +}; + +describe('Wizard AFTER — derived initial (furthest contiguously-reachable step)', () => { + // Parity with reducer.test.ts › "initialState — furthest contiguously-reachable step". + const initialOf = (descriptors: StepDescriptor[]) => { + const actor = createActor(createWizardMachine(descriptors)); + actor.start(); + return actor.getSnapshot(); + }; + + it('all guards false but entry → step 0', () => { + expect(initialOf(monotonic(false, false, false)).value).toBe('a'); + }); + + it('entry + next reachable → that step', () => { + expect(initialOf(monotonic(true, false, false)).value).toBe('b'); + }); + + it('two steps reachable → the second gate', () => { + expect(initialOf(monotonic(true, true, false)).value).toBe('c'); + }); + + it('everything reachable → last step', () => { + expect(initialOf(monotonic(true, true, true)).value).toBe('d'); + }); + + it('stops at the first gate (does not jump a closed guard)', () => { + expect(initialOf(monotonic(true, false, true)).value).toBe('b'); + }); + + it('seeds direction 0 and hasNavigated false', () => { + const snap = initialOf(monotonic(true, false, false)); + expect(snap.context.direction).toBe(0); + expect(snap.context.hasNavigated).toBe(false); + }); + + it('degenerate empty graph → empty value, no crash (with or without start)', () => { + expect(createActor(createWizardMachine([])).getSnapshot().value).toBe(''); + const started = createActor(createWizardMachine([])); + started.start(); + expect(started.getSnapshot().value).toBe(''); + }); +}); + +describe('Wizard AFTER — referential identity + no notify on every no-op path', () => { + // Parity with reducer.test.ts › "reduce — referential identity on every no-op + // path". The reducer asserts `=== state`; the actor's stronger equivalent is + // "getSnapshot() unchanged AND subscribers not notified". + const expectNoOp = (descriptors: StepDescriptor[], from: string, event: WizardEvent) => { + const actor = actorAt(descriptors, from); + const before = actor.getSnapshot(); + const seen: string[] = []; + actor.subscribe(s => seen.push(s.value)); + actor.send(event); + expect(actor.getSnapshot()).toBe(before); // referentially unchanged + expect(seen).toEqual([]); // subscribers NOT notified + }; + + it('NEXT at terminal → same ref, no notify', () => { + expectNoOp(monotonic(true, true, true), 'd', { type: 'NEXT' }); + }); + + it('NEXT blocked by next guard → same ref, no notify', () => { + expectNoOp(monotonic(false, false, false), 'a', { type: 'NEXT' }); + }); + + it('a hard stop mid-flow does not skip ahead to a later open guard', () => { + // b open, c closed, d open. From b, NEXT targets c (closed) → no-op. + expectNoOp(monotonic(true, false, true), 'b', { type: 'NEXT' }); + }); + + it('PREV at first position → same ref, no notify', () => { + expectNoOp(monotonic(true, true, true), 'a', { type: 'PREV' }); + }); + + it('PREV blocked by predecessor guard (non-monotonic) → same ref, no notify', () => { + const steps: StepDescriptor[] = [{ id: 'a' }, { id: 'b', guard: () => false }, { id: 'c', guard: () => true }]; + expectNoOp(steps, 'c', { type: 'PREV' }); + }); + + it('GOTO unknown id → same ref, no notify', () => { + expectNoOp(monotonic(true, true, true), 'a', { type: 'GOTO', step: 'zzz' }); + }); + + it('GOTO current id → same ref, no notify', () => { + expectNoOp(monotonic(true, true, true), 'a', { type: 'GOTO', step: 'a' }); + }); + + it('GOTO blocked by target guard → same ref, no notify', () => { + expectNoOp(monotonic(true, true, false), 'a', { type: 'GOTO', step: 'd' }); + }); + + it('unknown event type → same ref, no notify', () => { + // @ts-expect-error exercising an event the machine does not handle + expectNoOp(monotonic(true, true, true), 'a', { type: 'NOPE' }); + }); + + // NOTE: reducer.test.ts also pins "NEXT/PREV on unknown current → same ref". + // That case is structurally impossible in the actor model — an actor only ever + // occupies a real state — so it needs no test. The machine REMOVES that failure + // mode rather than guarding against it. +}); + +describe('Wizard AFTER — NEXT sequential + guard-gated', () => { + // Parity with reducer.test.ts › "reduce — NEXT sequential + guard-gated". + it('advances exactly one slot when the next guard holds', () => { + const actor = actorAt(monotonic(true, false, false), 'a'); + actor.send({ type: 'NEXT' }); + const snap = actor.getSnapshot(); + expect(snap.value).toBe('b'); + expect(snap.context.direction).toBe(1); + expect(snap.context.hasNavigated).toBe(true); + }); + + it('does NOT skip a satisfied step — one slot only (a → b, not d)', () => { + const actor = actorAt(monotonic(true, true, true), 'a'); + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().value).toBe('b'); + }); +}); + +describe('Wizard AFTER — PREV positional + guard-gated', () => { + // Parity with reducer.test.ts › "reduce — PREV positional + guard-gated". + it('walks exactly one declaration slot back', () => { + const actor = actorAt(monotonic(true, true, true), 'c'); + actor.send({ type: 'PREV' }); + const snap = actor.getSnapshot(); + expect(snap.value).toBe('b'); + expect(snap.context.direction).toBe(-1); + expect(snap.context.hasNavigated).toBe(true); + }); + + it('is positional, not history-based (from d → c regardless of arrival path)', () => { + const actor = actorAt(monotonic(true, true, true), 'd'); + actor.send({ type: 'PREV' }); + expect(actor.getSnapshot().value).toBe('c'); + }); +}); + +describe('Wizard AFTER — GOTO guard-gated', () => { + // Parity with reducer.test.ts › "reduce — GOTO guard-gated". + it('jumps to a reachable target', () => { + const actor = actorAt(monotonic(true, true, true), 'a'); + actor.send({ type: 'GOTO', step: 'c' }); + const snap = actor.getSnapshot(); + expect(snap.value).toBe('c'); + expect(snap.context.direction).toBe(0); + expect(snap.context.hasNavigated).toBe(true); + }); + + it('jumps backward to a reachable target', () => { + const actor = actorAt(monotonic(true, true, true), 'd'); + actor.send({ type: 'GOTO', step: 'a' }); + expect(actor.getSnapshot().value).toBe('a'); + }); +}); + +describe('Wizard AFTER — stepper view derived by introspection', () => { + it('isCompleted is positional; isReachable is guard-driven', () => { + const machine = createWizardMachine([{ id: 'a' }, { id: 'b', guard: () => true }, { id: 'c', guard: () => false }]); + const view = deriveStepper(machine, 'b'); + expect(view).toEqual([ + { id: 'a', isCompleted: true, isReachable: true }, // before current, no guard + { id: 'b', isCompleted: false, isReachable: true }, // current, guard holds + { id: 'c', isCompleted: false, isReachable: false }, // after current, gated out + ]); + }); + + it('enumerates every step in declaration order without running anything', () => { + const machine = createWizardMachine([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + expect(Object.keys(machine.states)).toEqual(['a', 'b', 'c']); + }); +}); + +describe('Wizard AFTER — re-seat replaces the render-phase clamp (via recheck)', () => { + // The seam's render-phase "clamp" (re-seat off a step whose entry guard broke) + // becomes a single `actor.recheck()` call when external data changes. + it('re-seats to the furthest-reachable step when the active guard breaks', () => { + let cOpen = true; + const descriptors: StepDescriptor[] = [ + { id: 'a' }, + { id: 'b', guard: () => true }, + { id: 'c', guard: () => cOpen }, + ]; + const actor = createActor(createWizardMachine(descriptors)); + actor.start(); + expect(actor.getSnapshot().value).toBe('c'); // furthest reachable + + cOpen = false; // the connection backing c is deleted elsewhere + actor.recheck(); + expect(actor.getSnapshot().value).toBe('b'); // re-seated to furthest reachable + }); + + it('re-seats all the way to the entry step when every later guard breaks', () => { + let unlocked = true; + const descriptors: StepDescriptor[] = [ + { id: 'a' }, + { id: 'b', guard: () => unlocked }, + { id: 'c', guard: () => unlocked }, + ]; + const actor = createActor(createWizardMachine(descriptors)); + actor.start(); + expect(actor.getSnapshot().value).toBe('c'); + + unlocked = false; + actor.recheck(); + expect(actor.getSnapshot().value).toBe('a'); + }); + + it('does NOT move when a later guard opens while the current step still holds', () => { + let bOpen = false; + const descriptors: StepDescriptor[] = [{ id: 'a' }, { id: 'b', guard: () => bOpen }]; + const actor = createActor(createWizardMachine(descriptors), { snapshot: { value: 'a' } }); + + bOpen = true; // b opens, but a still holds → recheck must not yank forward + actor.recheck(); + expect(actor.getSnapshot().value).toBe('a'); + }); +}); + +describe('Wizard AFTER — deferred advance is unnecessary outside React', () => { + // The seam's `pendingNextFrom` parked a NEXT to survive React's render-phase + // staleness. The actor holds live state, so `send` reads the live guard + // synchronously: await the mutation, then send NEXT — it advances at once. No + // parking, no "click twice". + it('a NEXT blocked by an unmet guard advances on the next send once data lands', () => { + let bReady = false; + const descriptors: StepDescriptor[] = [{ id: 'a' }, { id: 'b', guard: () => bReady }]; + const actor = createActor(createWizardMachine(descriptors), { snapshot: { value: 'a' } }); + + actor.send({ type: 'NEXT' }); // b not ready → blocked no-op + expect(actor.getSnapshot().value).toBe('a'); + + bReady = true; // the awaited mutation + revalidate landed + actor.send({ type: 'NEXT' }); // same call site, now advances immediately + expect(actor.getSnapshot().value).toBe('b'); + }); +}); + +describe('Wizard AFTER — scope-boundary signal (the seam parent-link would hook)', () => { + // Parent/child bubbling is scoped to a follow-up PR. The boundary signal it + // would forward on is exactly the terminal/first same-ref no-op, asserted here. + it('terminal NEXT is a no-op AND `can` reports false (the bubble trigger)', () => { + const actor = actorAt([{ id: 'only' }], 'only'); // single step ⇒ terminal + const before = actor.getSnapshot(); + expect(actor.can({ type: 'NEXT' })).toBe(false); + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot()).toBe(before); // unchanged → a parent would bubble + }); + + it('a guard-blocked mid-flow NEXT is distinguishable from a terminal one by position', () => { + // Both are same-ref no-ops, but `can` + the step's position in machine.states + // tell them apart — the seam used the index to decide bubble vs hard-stop. + const descriptors = [{ id: 'a' }, { id: 'b', guard: () => false }]; + const actor = actorAt(descriptors, 'a'); + expect(actor.can({ type: 'NEXT' })).toBe(false); // blocked, but... + const ids = Object.keys(createWizardMachine(descriptors).states); + expect(ids.indexOf('a')).not.toBe(ids.length - 1); // ...not terminal → hard stop, not bubble + }); +}); + +// ── Driven through the React hooks (mirrors useMachine.test.tsx patterns) ────── + +const STEP_LABEL: Record = { intro: 'Welcome', details: 'Your details', review: 'Review & submit' }; + +describe('Wizard AFTER — driven through useMachine', () => { + it('advances and retreats the displayed step on NEXT/PREV and no-ops at the terminal', () => { + const descriptors: StepDescriptor[] = [ + { id: 'intro' }, + { id: 'details', guard: () => true }, + { id: 'review', guard: () => true }, + ]; + const machine = createWizardMachine(descriptors); + + function Wizard() { + // Seed at the first step (the seam's `initialStepId`). A guardless wizard's + // furthest-reachable derivation would otherwise mount on the LAST step — so + // a linear "click through" demo starts the actor at the head explicitly. + const [snapshot, send] = useMachine(machine, { snapshot: { value: 'intro' } }); + return ( +
+ {STEP_LABEL[snapshot.value]} + + +
+ ); + } + + render(); + expect(screen.getByTestId('step')).toHaveTextContent('Welcome'); + + fireEvent.click(screen.getByText('Next')); + expect(screen.getByTestId('step')).toHaveTextContent('Your details'); + + fireEvent.click(screen.getByText('Next')); + expect(screen.getByTestId('step')).toHaveTextContent('Review & submit'); + + // Terminal: NEXT is a no-op, the step does not change. + fireEvent.click(screen.getByText('Next')); + expect(screen.getByTestId('step')).toHaveTextContent('Review & submit'); + + fireEvent.click(screen.getByText('Back')); + expect(screen.getByTestId('step')).toHaveTextContent('Your details'); + }); +}); + +describe('Wizard AFTER — useSelector scopes re-renders to the step slice', () => { + it('a stepper re-renders on navigation but not on an unrelated context change', () => { + const machine = createWizardMachine([{ id: 'intro' }, { id: 'details', guard: () => true }]); + // Seed at 'intro' (furthest-reachable would land on 'details' otherwise). + const actor = createActor(machine, { snapshot: { value: 'intro' } }); + actor.start(); + + const renders = vi.fn(); + function StepIndicator() { + const current = useSelector(actor, s => s.value); + renders(); + return {current}; + } + + render(); + expect(renders).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('current')).toHaveTextContent('intro'); + + // A GOTO to the current step is a no-op → no notify → no re-render. + act(() => actor.send({ type: 'GOTO', step: 'intro' })); + expect(renders).toHaveBeenCalledTimes(1); + + // A real navigation changes the selected slice → exactly one re-render. + act(() => actor.send({ type: 'NEXT' })); + expect(renders).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('current')).toHaveTextContent('details'); + }); +}); + +describe('Wizard AFTER — mockActor teleports into an otherwise-unreachable step', () => { + it('renders a gated step the user could never click into (the swingset seam)', () => { + // `locked` is gated out, so no sequence of NEXT/GOTO reaches it. Teleport in to + // snapshot its content — exactly the docs use case. + const machine = createWizardMachine([{ id: 'intro' }, { id: 'locked', guard: () => false }]); + const actor = mockActor(machine, { value: 'locked' }); + + function Step() { + const [snapshot] = useActor(actor); + return
{snapshot.value === 'locked' ? 'Locked step content' : snapshot.value}
; + } + + render(); + expect(screen.getByText('Locked step content')).toBeInTheDocument(); + expect(actor.getSnapshot().value).toBe('locked'); + }); +}); diff --git a/packages/ui/src/mosaic/machine/assign.ts b/packages/ui/src/mosaic/machine/assign.ts new file mode 100644 index 00000000000..9822f37d416 --- /dev/null +++ b/packages/ui/src/mosaic/machine/assign.ts @@ -0,0 +1,26 @@ +import { ASSIGN } from './types'; +import type { AssignAction, EventObject } from './types'; + +/** + * Context-update action creator. The returned object is recognised by the + * runtime, which shallow-merges the returned partial into context. + * + * ```ts + * on: { TYPE: { actions: assign((_, e) => ({ value: e.value })) } } + * ``` + * + * The updater is a pure `(context, event) => Partial` function, so it + * can be unit-tested on its own without an actor. + */ +export function assign( + assignment: (context: TContext, event: TEvent) => Partial, +): AssignAction { + return { type: ASSIGN, assignment }; +} + +/** Type guard distinguishing an `assign` action from a plain side-effect action. */ +export function isAssignAction( + action: unknown, +): action is AssignAction { + return typeof action === 'object' && action !== null && (action as { type?: unknown }).type === ASSIGN; +} diff --git a/packages/ui/src/mosaic/machine/createActor.ts b/packages/ui/src/mosaic/machine/createActor.ts new file mode 100644 index 00000000000..c775b8bbf18 --- /dev/null +++ b/packages/ui/src/mosaic/machine/createActor.ts @@ -0,0 +1,318 @@ +import { isAssignAction } from './assign'; +import { AFTER, INIT, INVOKE_DONE, INVOKE_ERROR, RECHECK } from './types'; +import type { + Actions, + Actor, + AfterEvent, + AnyEventObject, + CreateActorOptions, + EventObject, + Snapshot, + SnapshotListener, + StateConfig, + StateMachine, + TransitionConfig, + Unsubscribe, +} from './types'; + +const INIT_EVENT: AnyEventObject = { type: INIT }; + +function toArray(value: T | T[] | undefined): T[] { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value]; +} + +/** Normalise a transition (string | object | array) into an ordered list of configs. */ +function normalizeTransitions( + raw: unknown, +): TransitionConfig[] { + return toArray(raw as TransitionConfig | string | undefined).map(entry => + typeof entry === 'string' ? { target: entry } : entry, + ); +} + +/** + * Wrap a machine definition in a running instance (an "actor"). + * + * Construction is lazy: the snapshot reflects the initial state, but entry + * actions, immediate (`always`) transitions, and invokes don't run until + * {@link Actor.start} is called. + * + * ```ts + * const actor = createActor(machine); + * actor.subscribe(snap => console.log(snap.value)); + * actor.start(); + * actor.send({ type: 'TOGGLE' }); + * ``` + */ +export function createActor( + machine: StateMachine, + options: CreateActorOptions = {}, +): Actor { + const teleport = options.snapshot; + + // Internally the actor operates on the broader `EventObject`: invoke done/error + // events aren't part of the user's `TEvent` union, so the config is viewed + // through an event-agnostic lens to keep the runtime helpers honestly typed. + const states = machine.states as unknown as Record>; + + // Tracks the latest setContext patch so it survives a stop/start cycle. + let liveContextPatch: Partial = options.context ?? {}; + let context: TContext = { ...machine.context, ...liveContextPatch, ...teleport?.context }; + // `initial` may be derived from context (e.g. furthest-reachable step). + const resolveInitial = () => (typeof machine.initial === 'function' ? machine.initial(context) : machine.initial); + let value = teleport?.value ?? resolveInitial(); + + // A teleported actor is already "started" and inert: start() must not re-run + // entry/always/invoke for the state it was dropped into. + let started = teleport !== undefined; + let status: Snapshot['status'] = states[value]?.type === 'final' ? 'done' : 'active'; + + // Bumped whenever we leave an invoking state (or stop), so a stale promise + // resolving after the fact is ignored — no transition, no setState-after-stop. + let invocationToken = 0; + + // Pending `after` timer IDs — cleared when the state is exited or the actor stops. + let afterTimers: ReturnType[] = []; + + // The snapshot is cached and only replaced on an actual change, so + // getSnapshot() is referentially stable for useSyncExternalStore. + let snapshot: Snapshot = { value, context, status }; + + const listeners = new Set>(); + + function runActions(actions: Actions | undefined, event: EventObject): void { + for (const action of toArray(actions)) { + if (isAssignAction(action)) { + context = { ...context, ...action.assignment(context, event) }; + } else { + action(context, event); + } + } + } + + function pickTransition( + transitions: TransitionConfig[], + event: EventObject, + ): TransitionConfig | undefined { + return transitions.find(transition => !transition.guard || transition.guard(context, event)); + } + + /** Whether a target state's entry guard currently permits landing on it. */ + function canEnter(stateId: string, event: EventObject): boolean { + const guard = states[stateId]?.guard; + return !guard || guard(context, event); + } + + /** + * Run a chosen transition: exit (if external) → actions → enter target. + * Returns `false` — a true no-op — when the target's entry guard blocks it, so + * the caller skips the commit and subscribers are never notified. + */ + function takeTransition(transition: TransitionConfig, event: EventObject): boolean { + const external = transition.target !== undefined; + if (external && !canEnter(transition.target as string, event)) { + return false; // entry guard blocks landing → snapshot unchanged, no notify + } + if (external) { + runActions(states[value].exit, event); + invocationToken++; // abandon the invoke of the state we're leaving + clearAfterTimers(); + } + runActions(transition.actions, event); + if (external) { + value = transition.target as string; + enterState(event); + } + return true; + } + + function startInvoke(event: EventObject): void { + const invoke = states[value].invoke; + if (!invoke) return; + const token = ++invocationToken; + Promise.resolve(invoke.src(context, event as never)).then( + output => { + if (status !== 'active' || token !== invocationToken) return; + const doneEvent = { type: INVOKE_DONE, output }; + const transition = pickTransition(normalizeTransitions(invoke.onDone), doneEvent); + if (!transition) return; + takeTransition(transition, doneEvent); + commit(); + }, + (error: unknown) => { + if (status !== 'active' || token !== invocationToken) return; + const errorEvent = { type: INVOKE_ERROR, error }; + const transition = pickTransition(normalizeTransitions(invoke.onError), errorEvent); + if (!transition) return; + takeTransition(transition, errorEvent); + commit(); + }, + ); + } + + function clearAfterTimers(): void { + for (const id of afterTimers) clearTimeout(id); + afterTimers = []; + } + + function startAfterTimers(): void { + const afterConfig = states[value].after; + if (!afterConfig) return; + for (const [delayStr, raw] of Object.entries(afterConfig)) { + const delay = Number(delayStr); + // normalizeTransitions takes `raw: unknown`, so the AfterEvent↔EventObject + // mismatch is resolved at the parameter boundary, not by a cast here. + const transitions = normalizeTransitions(raw); + const id = setTimeout(() => { + afterTimers = afterTimers.filter(t => t !== id); + if (status !== 'active') return; + const afterEvent: AfterEvent = { type: AFTER, delay }; + const transition = pickTransition(transitions, afterEvent); + if (!transition) return; + if (takeTransition(transition, afterEvent)) commit(); + }, delay); + afterTimers.push(id); + } + } + + /** Entry side of a state: entry actions, then immediate/invoke resolution. */ + function enterState(event: EventObject): void { + const stateConfig = states[value]; + if (!stateConfig) return; // degenerate graph (e.g. empty wizard) — nothing to enter + runActions(stateConfig.entry, event); + + if (stateConfig.type === 'final') { + status = 'done'; + return; + } + + const immediate = pickTransition(normalizeTransitions(stateConfig.always), event); + if (immediate && immediate.target !== undefined && takeTransition(immediate, event)) { + return; + } + + startInvoke(event); + startAfterTimers(); + } + + function commit(): void { + snapshot = { value, context, status }; + for (const listener of listeners) { + listener(snapshot); + } + } + + const actor: Actor = { + start() { + if (started) return actor; + started = true; + status = 'active'; + // Reset state and context so a restart (e.g. after StrictMode stop/start) + // begins from idle rather than re-entering and re-invoking a mid-flight state. + context = { ...machine.context, ...liveContextPatch }; + value = resolveInitial(); + enterState(INIT_EVENT); + commit(); + return actor; + }, + + stop() { + if (status === 'stopped') return; + started = false; // allow restart (e.g. StrictMode effect cleanup + remount) + status = 'stopped'; + invocationToken++; // abandon any in-flight invoke + clearAfterTimers(); + snapshot = { value, context, status }; + for (const listener of listeners) { + listener(snapshot); + } + listeners.clear(); + }, + + send(event) { + if (status !== 'active') return; + const transition = pickTransition(normalizeTransitions(states[value]?.on?.[event.type]), event); + if (!transition) return; // event not handled in this state → ignored + if (takeTransition(transition, event)) commit(); // entry-blocked → no commit, no notify + }, + + getSnapshot() { + return snapshot; + }, + + // Standard observable contract: `subscribe(cb)` returns an unsubscribe fn. + // This is deliberately the same shape as a nanostores atom's `subscribe`, so + // if nanostores is adopted repo-wide later a `toAtom(actor)` adapter is a + // trivial, non-breaking wrapper over `subscribe` + `getSnapshot` — no need to + // pull the dependency in now. + subscribe(listener: SnapshotListener): Unsubscribe { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + can(event) { + if (status !== 'active') return false; + const transition = pickTransition(normalizeTransitions(states[value]?.on?.[event.type]), event); + if (!transition) return false; + return transition.target === undefined || canEnter(transition.target as string, event); + }, + + setContext(patch: Partial) { + liveContextPatch = { ...liveContextPatch, ...patch }; + context = { ...context, ...patch }; + }, + + recheck() { + if (status !== 'active') return; + const event = { type: RECHECK }; + + // Self-correct first: if live external data has made the *current* state + // unenterable (its entry guard no longer holds), re-seat to the freshly + // resolved initial state — the same derivation used on start (e.g. the + // Wizard's furthest-reachable step). `resolveInitial` always lands on an + // enterable step, so this is provably one-shot and cannot loop. + if (!canEnter(value, event)) { + const reseated = resolveInitial(); + if (reseated !== value) { + runActions(states[value]?.exit, event); + invocationToken++; // abandon the invoke of the state we're leaving + clearAfterTimers(); + value = reseated; + enterState(event); + commit(); + } + return; + } + + const immediate = pickTransition(normalizeTransitions(states[value]?.always), event); + if (immediate && immediate.target !== undefined && takeTransition(immediate, event)) { + commit(); // nothing applies → no commit, no notify + } + }, + }; + + return actor; +} + +/** + * Create an actor teleported to an arbitrary `{ value, context }`. + * + * The actor is started but inert: no entry actions, immediate transitions, or + * invokes run for the teleported state. This is the key docs/testing affordance + * — transient states (e.g. a `deleting` step hidden behind a 2s mutation) can't + * be reached by clicking through, so teleport straight to them for a snapshot. + * + * ```ts + * const actor = mockActor(machine, { value: 'deleting', context: { error: null } }); + * actor.getSnapshot(); // → { value: 'deleting', context: {...}, status: 'active' } + * ``` + */ +export function mockActor( + machine: StateMachine, + snapshot: { value: string; context?: Partial }, +): Actor { + return createActor(machine, { snapshot }); +} diff --git a/packages/ui/src/mosaic/machine/createMachine.ts b/packages/ui/src/mosaic/machine/createMachine.ts new file mode 100644 index 00000000000..bff31208d77 --- /dev/null +++ b/packages/ui/src/mosaic/machine/createMachine.ts @@ -0,0 +1,35 @@ +import type { EventObject, MachineConfig, StateMachine } from './types'; + +/** + * Create a state-machine definition from a config object. + * + * The result is a plain, inert description — nothing runs until you wrap it in + * an actor ({@link createActor}). Because the shape is a static object, tools + * (swingset docs, tests) can read `machine.states` to enumerate every step + * without executing anything. + * + * ```ts + * const machine = createMachine({ + * id: 'toggle', + * initial: 'inactive', + * context: { count: 0 }, + * states: { + * inactive: { on: { TOGGLE: 'active' } }, + * active: { on: { TOGGLE: 'inactive' } }, + * }, + * }); + * ``` + */ +export function createMachine< + TContext extends object = Record, + TEvent extends EventObject = EventObject, + TStates extends string = string, +>(config: MachineConfig): StateMachine { + return { + id: config.id, + initial: config.initial, + context: config.context ?? ({} as TContext), + states: config.states, + config, + }; +} diff --git a/packages/ui/src/mosaic/machine/setup.ts b/packages/ui/src/mosaic/machine/setup.ts new file mode 100644 index 00000000000..c7269c6d737 --- /dev/null +++ b/packages/ui/src/mosaic/machine/setup.ts @@ -0,0 +1,84 @@ +import { assign as _assign } from './assign'; +import { createMachine as _createMachine } from './createMachine'; +import type { + AssignAction, + DoneInvokeEvent, + ErrorInvokeEvent, + EventObject, + InvokeConfig, + MachineConfig, + StateMachine, + Transition, +} from './types'; + +/** + * Pre-bind `TContext` and `TEvent` once per machine file, returning factory + * functions that don't require repeating those types at every call site. + * + * ```ts + * const { createMachine, assign } = setup(); + * + * export function createSignInMachine(deps: Deps) { + * return createMachine({ // no needed + * states: { + * collecting: { + * on: { + * TYPE_IDENTIFIER: { + * actions: assign((_, e) => ({ identifier: e.value })), // e narrowed automatically + * }, + * }, + * }, + * }, + * }); + * } + * ``` + * + * `assign`'s second type parameter (`TEvt`) is left free so TypeScript's + * contextual typing can narrow it from its placement inside `on`, `onDone`, + * `onError`, or `after` — eliminating the need to write + * `assign>` by hand. + */ +export function setup() { + return { + createMachine: (config: MachineConfig): StateMachine => + _createMachine(config), + + assign: ( + fn: (context: TContext, event: TEvt) => Partial, + ): AssignAction => _assign(fn), + + /** + * Wraps an async function so its resolved type flows into `onDone.actions`. + * Mirrors XState v5's `fromPromise()` — a typed wrapper that carries + * `TOutput` to `DoneInvokeEvent` without needing a full actor registry. + * + * ```ts + * const { createMachine, assign, fromPromise } = setup(); + * + * states: { + * loading: { + * invoke: fromPromise( + * ctx => ctx.fetchFn(), // infers TOutput = Resource + * { onDone: { target: 'success', + * actions: assign((_, e) => ({ data: e.output })) } }, // e.output: Resource ✓ + * ), + * }, + * } + * ``` + * + * A raw `src` function (without `fromPromise`) still works — `e.output` is `any`. + */ + fromPromise: ( + fn: (context: TContext) => Promise, + config?: { + onDone?: Transition, TStates>; + onError?: Transition; + }, + ): InvokeConfig => ({ + // Cast needed: fn only uses context (no event param), but InvokeConfig.src + // signature accepts (context, event) for parity with state-entry event access. + src: fn as unknown as InvokeConfig['src'], + ...config, + }), + }; +} diff --git a/packages/ui/src/mosaic/machine/types.test-d.ts b/packages/ui/src/mosaic/machine/types.test-d.ts new file mode 100644 index 00000000000..e79c16ee3dc --- /dev/null +++ b/packages/ui/src/mosaic/machine/types.test-d.ts @@ -0,0 +1,372 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import { assign } from './assign'; +import { createActor } from './createActor'; +import { createMachine } from './createMachine'; +import { setup } from './setup'; +import type { Snapshot, StateMachine } from './types'; + +// ─── Shared fixture ────────────────────────────────────────────────────────── + +type TestContext = { count: number; label: string }; + +type TestEvent = { type: 'SIMPLE' } | { type: 'WITH_PAYLOAD'; value: string } | { type: 'OPTIONAL'; count?: number }; + +const machine = createMachine({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { + idle: { + on: { + SIMPLE: 'idle', + WITH_PAYLOAD: 'idle', + OPTIONAL: 'idle', + }, + }, + }, +}); + +const actor = createActor(machine); + +// ─── actor.send — accepted events ──────────────────────────────────────────── + +describe('actor.send — accepts every member of the event union', () => { + test('event with no payload', () => { + actor.send({ type: 'SIMPLE' }); + }); + + test('event with required payload', () => { + actor.send({ type: 'WITH_PAYLOAD', value: 'hello' }); + }); + + test('event with optional payload omitted', () => { + actor.send({ type: 'OPTIONAL' }); + }); + + test('event with optional payload provided', () => { + actor.send({ type: 'OPTIONAL', count: 3 }); + }); +}); + +// ─── actor.send — rejected events ──────────────────────────────────────────── + +describe('actor.send — rejects events outside the union', () => { + test('unknown event type', () => { + // @ts-expect-error — 'UNKNOWN' is not in TestEvent + actor.send({ type: 'UNKNOWN' }); + }); + + test('missing required payload field', () => { + // @ts-expect-error — WITH_PAYLOAD requires `value: string` + actor.send({ type: 'WITH_PAYLOAD' }); + }); + + test('wrong payload field type', () => { + // @ts-expect-error — `value` must be string, not number + actor.send({ type: 'WITH_PAYLOAD', value: 123 }); + }); + + test('empty object', () => { + // @ts-expect-error — missing `type` + actor.send({}); + }); +}); + +// ─── actor.can — accepted / rejected ───────────────────────────────────────── + +describe('actor.can — mirrors send type safety', () => { + test('accepts a valid event', () => { + const result = actor.can({ type: 'SIMPLE' }); + expectTypeOf(result).toEqualTypeOf(); + }); + + test('accepts event with payload', () => { + actor.can({ type: 'WITH_PAYLOAD', value: 'x' }); + }); + + test('rejects unknown event type', () => { + // @ts-expect-error — 'NOPE' is not in TestEvent + actor.can({ type: 'NOPE' }); + }); + + test('rejects missing required payload', () => { + // @ts-expect-error — WITH_PAYLOAD requires `value` + actor.can({ type: 'WITH_PAYLOAD' }); + }); +}); + +// ─── snapshot types ─────────────────────────────────────────────────────────── + +describe('snapshot — context and value are correctly typed', () => { + test('context matches TContext', () => { + const snap = actor.getSnapshot(); + expectTypeOf(snap.context).toEqualTypeOf(); + }); + + test('value is string', () => { + const snap = actor.getSnapshot(); + expectTypeOf(snap.value).toEqualTypeOf(); + }); + + test('full snapshot matches Snapshot', () => { + const snap = actor.getSnapshot(); + expectTypeOf(snap).toEqualTypeOf>(); + }); +}); + +// ─── assign — updater types ─────────────────────────────────────────────────── + +describe('assign — updater receives typed context and event', () => { + test('context parameter is TContext', () => { + assign(ctx => { + expectTypeOf(ctx).toEqualTypeOf(); + return {}; + }); + }); + + test('event parameter is TEvent', () => { + assign((_, event) => { + expectTypeOf(event).toEqualTypeOf(); + return {}; + }); + }); + + test('return must be Partial — rejects unknown keys', () => { + // @ts-expect-error — 'unknown_key' is not in TestContext + assign(() => ({ unknown_key: true })); + }); +}); + +// ─── Actor.send signature ───────────────────────────────────────────────────── + +describe('Actor.send — signature is (event: TEvent) => void, not AnyEventObject', () => { + test('send is typed to TEvent', () => { + expectTypeOf(actor.send).toEqualTypeOf<(event: TestEvent) => void>(); + }); + + test('send does not widen to Record', () => { + expectTypeOf(actor.send).not.toMatchTypeOf<(event: Record) => void>(); + }); +}); + +// ─── createMachine — on key and target type safety ─────────────────────────── + +describe('createMachine — on keys are constrained to TEvent types', () => { + test('valid event type in on compiles', () => { + createMachine({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { idle: { on: { SIMPLE: 'idle' } } }, + }); + }); + + test('unknown event type in on is rejected', () => { + createMachine({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { + // @ts-expect-error — 'UNKNOWN_EVENT' is not in TestEvent['type'] + idle: { on: { UNKNOWN_EVENT: 'idle' } }, + }, + }); + }); +}); + +describe('createMachine — TStates constrains initial and transition targets', () => { + type S = 'idle' | 'active'; + + test('valid initial compiles', () => { + createMachine({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { idle: { on: { SIMPLE: 'active' } }, active: {} }, + }); + }); + + test('invalid initial is rejected', () => { + createMachine({ + // @ts-expect-error — 'missing' is not in 'idle' | 'active' + initial: 'missing', + context: { count: 0, label: '' }, + states: { idle: {}, active: {} }, + }); + }); + + test('invalid transition target is rejected', () => { + createMachine({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { + idle: { + // @ts-expect-error — 'nonexistent' is not in 'idle' | 'active' + on: { SIMPLE: 'nonexistent' }, + }, + active: {}, + }, + }); + }); +}); + +// ─── setup — pre-binds TContext and TEvent ─────────────────────────────────── + +describe('setup — eliminates repetitive generic params', () => { + const { createMachine: make, assign: a } = setup(); + + test('createMachine compiles without explicit type parameters', () => { + const m = make({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { idle: { on: { SIMPLE: 'idle' } } }, + }); + expectTypeOf(m).toMatchTypeOf>(); + }); + + test('assign in on handler infers the narrowed event type — no Extract<> needed', () => { + make({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { + idle: { + on: { + WITH_PAYLOAD: { + // e should be { type: 'WITH_PAYLOAD'; value: string }, not the full union + actions: a((_, e) => { + expectTypeOf(e).toEqualTypeOf<{ type: 'WITH_PAYLOAD'; value: string }>(); + return { label: e.value }; + }), + }, + }, + }, + }, + }); + }); + + test('assign context return type is enforced — rejects unknown keys', () => { + make({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { + idle: { + on: { + SIMPLE: { + // @ts-expect-error — 'unknown_key' is not in TestContext + actions: a(() => ({ unknown_key: true })), + }, + }, + }, + }, + }); + }); + + test('on key constraint still holds — unknown event types are rejected', () => { + make({ + initial: 'idle', + context: { count: 0, label: '' }, + states: { + // @ts-expect-error — 'UNKNOWN_EVENT' is not in TestEvent['type'] + idle: { on: { UNKNOWN_EVENT: 'idle' } }, + }, + }); + }); +}); + +// ─── setup.fromPromise — typed invoke output ────────────────────────────────── + +type FetchContext = { url: string; data: string | null; error: string | null }; +type FetchEvent = { type: 'FETCH' } | { type: 'RESET' }; + +interface FetchResult { + body: string; + status: number; +} + +describe('setup.fromPromise — e.output is typed from src return type', () => { + const { createMachine: make2, assign: a2, fromPromise } = setup(); + + // Typed async function — fromPromise infers TOutput = FetchResult from its return type. + const fakeFetch = (ctx: FetchContext): Promise => Promise.resolve({ body: ctx.url, status: 200 }); + + test('e.output in onDone is typed to TOutput, not unknown or any', () => { + make2({ + initial: 'idle', + context: { url: '', data: null, error: null }, + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { + invoke: fromPromise(fakeFetch, { + onDone: { + target: 'idle', + actions: a2((_, e) => { + // e.output should be FetchResult, not unknown or any + expectTypeOf(e.output).toEqualTypeOf(); + return { data: e.output.body }; + }), + }, + }), + }, + }, + }); + }); + + test('e.output allows accessing known fields without a cast', () => { + make2({ + initial: 'idle', + context: { url: '', data: null, error: null }, + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { + invoke: fromPromise(fakeFetch, { + onDone: { + target: 'idle', + // Accessing .body and .status directly — no cast needed + actions: a2((_, e) => ({ data: e.output.body + e.output.status })), + }, + }), + }, + }, + }); + }); + + test('assign context return type still enforced inside fromPromise onDone', () => { + make2({ + initial: 'idle', + context: { url: '', data: null, error: null }, + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { + invoke: fromPromise(fakeFetch, { + onDone: { + target: 'idle', + // @ts-expect-error — 'unknown_key' is not in FetchContext + actions: a2(() => ({ unknown_key: true })), + }, + }), + }, + }, + }); + }); + + test('a raw src function still works — e.output is any', () => { + make2({ + initial: 'idle', + context: { url: '', data: null, error: null }, + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { + invoke: { + // Raw function: output type is not carried through + src: async (ctx: FetchContext) => Promise.resolve(ctx.url), + onDone: { + target: 'idle', + actions: a2((_, e) => { + // any — document that fromPromise is required for typed output + expectTypeOf(e.output).toBeAny(); + return {}; + }), + }, + }, + }, + }, + }); + }); +}); diff --git a/packages/ui/src/mosaic/machine/types.ts b/packages/ui/src/mosaic/machine/types.ts new file mode 100644 index 00000000000..02dcffffd72 --- /dev/null +++ b/packages/ui/src/mosaic/machine/types.ts @@ -0,0 +1,240 @@ +/** + * Shared types for the Mosaic state-machine library. + * + * The design mirrors XState v5's config-object shape (so a machine is statically + * introspectable — see {@link StateMachine.states}) but is trimmed to a tiny, + * dependency-free core: no parallel states, history, SCXML, or spawned actors. + */ + +/** The minimum shape every event must have. */ +export interface EventObject { + type: string; +} + +/** A loosely-typed event accepted by `send`/`can` (e.g. `{ type: 'TYPE', value: 'foo' }`). */ +export interface AnyEventObject extends EventObject { + [key: string]: unknown; +} + +/** Pure predicate that gates a transition. */ +export type Guard = (context: TContext, event: TEvent) => boolean; + +/** A side-effecting action — runs for its effect, returns nothing. */ +export type ActionFunction = (context: TContext, event: TEvent) => void; + +/** Internal tag identifying an {@link assign} action. A symbol so it can never collide with a user value. */ +export const ASSIGN = Symbol('assign'); + +/** + * The object produced by {@link assign}. Tagged so the runtime can tell a + * context-updating action apart from a plain side-effect action. + */ +export interface AssignAction { + type: typeof ASSIGN; + assignment: (context: TContext, event: TEvent) => Partial; +} + +/** Either a side-effect or an `assign` context update. */ +export type Action = + | ActionFunction + | AssignAction; + +export type Actions = Action | Action[]; + +/** The long form of a transition. */ +export interface TransitionConfig { + /** State to enter. Omit for an internal transition (runs actions, stays put). */ + target?: TStates; + /** Actions to run during the transition, in order. */ + actions?: Actions; + /** Only take this transition when the guard passes. */ + guard?: Guard; +} + +/** + * A transition may be a bare target string, a config object, or an array of + * configs evaluated in order (first passing guard wins). + */ +export type Transition = + | TStates + | TransitionConfig + | TransitionConfig[]; + +/** The event type fired when an invoked promise resolves. */ +export const INVOKE_DONE = 'machine.invoke.done'; +/** The event type fired when an invoked promise rejects. */ +export const INVOKE_ERROR = 'machine.invoke.error'; +/** The event delivered to a state on entry (and to the initial state on start). */ +export const INIT = 'machine.init'; +/** The event delivered to guards when {@link Actor.recheck} re-evaluates `always` transitions. */ +export const RECHECK = 'machine.recheck'; +/** The event type delivered to an `after` transition when its timer fires. */ +export const AFTER = 'machine.after'; + +/** Event delivered to `onDone` when an invoked promise resolves. */ +export interface DoneInvokeEvent extends EventObject { + type: typeof INVOKE_DONE; + output: TOutput; +} + +/** Event delivered to `onError` when an invoked promise rejects. */ +export interface ErrorInvokeEvent extends EventObject { + type: typeof INVOKE_ERROR; + error: unknown; +} + +/** Event delivered to an `after` transition when its timer fires. */ +export interface AfterEvent extends EventObject { + type: typeof AFTER; + delay: number; +} + +/** Invoke a promise on state entry and branch on its settlement. */ +export interface InvokeConfig< + TContext, + TEvent extends EventObject, + TOutput = unknown, + TStates extends string = string, +> { + /** Started on entry. The resolved value lands on `onDone` events as `output`. */ + src: (context: TContext, event: TEvent | DoneInvokeEvent | ErrorInvokeEvent) => Promise; + onDone?: Transition, TStates>; + onError?: Transition; +} + +export interface StateConfig { + /** + * Entry precondition — "may navigation LAND on this state right now?". Checked + * uniformly by *every* transition (and the derived initial) that targets this + * state: when it fails, the transition is a true no-op (snapshot unchanged, no + * notify). Distinct from a transition `guard`, which gates a single edge. An + * omitted entry guard means "always enterable". Often reads live external data + * via closure rather than `context` — pair with {@link Actor.recheck}. + */ + guard?: Guard; + /** + * Event-name → transition map. Each key is constrained to `TEvent['type']` + * and the transition's guards/actions receive the narrowed event member — + * e.g. a guard under `on['SUBMIT']` sees `Extract`, + * not the full union. + */ + on?: { [K in TEvent['type']]?: Transition, TStates> }; + /** Eventless / immediate transitions, evaluated on entry and on {@link Actor.recheck}. */ + always?: Transition; + /** + * Delayed transitions — each key is a delay in milliseconds. The matching + * transition fires automatically after the delay unless the state is exited + * first (by an explicit event, `always`, or `invoke`). Timers are cancelled + * on exit and on `stop()`, so they never outlive the state or the actor. + * + * ```ts + * codeSent: { + * after: { 60_000: 'expired' }, + * on: { SUBMIT: 'verifying' }, + * } + * ``` + */ + after?: { [delay: number]: Transition }; + /** + * A promise to invoke on entry. Use {@link PromiseSrc} (created by + * `setup().fromPromise`) to carry the resolved type to `onDone.actions`. + * A raw `src` function is also accepted — `e.output` is `any` in that case. + */ + invoke?: InvokeConfig; + /** Actions run when the state is entered. */ + entry?: Actions; + /** Actions run when the state is exited. */ + exit?: Actions; + /** A terminal state — no further events are processed once reached. */ + type?: 'final'; +} + +/** + * The initial state may be a static id or derived from context at start time + * (e.g. the Wizard computes the "furthest-reachable" step from its entry guards). + */ +export type InitialResolver = (context: TContext) => TStates; + +export interface MachineConfig { + id?: string; + initial: TStates | InitialResolver; + context?: TContext; + states: Record>; +} + +/** + * The static, runnable-instance-free machine definition returned by + * {@link createMachine}. `states` is exposed so docs/tests can enumerate every + * step without running anything. + */ +export interface StateMachine { + id: string | undefined; + initial: string | InitialResolver; + context: TContext; + states: Record>; + config: MachineConfig; +} + +export type ActorStatus = 'active' | 'done' | 'stopped'; + +/** A point-in-time view of a running actor. */ +export interface Snapshot { + value: string; + context: TContext; + status: ActorStatus; +} + +/** A subscriber receives the latest snapshot on every transition. */ +export type SnapshotListener = (snapshot: Snapshot) => void; + +/** Standard observable contract: `subscribe` returns its own unsubscribe fn. */ +export type Unsubscribe = () => void; + +export interface Actor { + /** Run entry actions / immediate transitions / invokes of the initial state. */ + start: () => Actor; + /** Stop the actor and abandon any in-flight invoke. */ + stop: () => void; + /** Send an event. Ignored once the actor is `done` or `stopped`. */ + send: (event: TEvent) => void; + getSnapshot: () => Snapshot; + /** Subscribe to snapshots; returns an unsubscribe fn (usable with `useSyncExternalStore`). */ + subscribe: (listener: SnapshotListener) => Unsubscribe; + /** Whether the event would be handled (a guard-passing, enterable transition exists) right now. */ + can: (event: TEvent) => boolean; + /** + * Silently merge a partial context patch into the running actor — no snapshot + * emitted, no transitions triggered. Use this to keep injected dependencies + * (e.g. an async function from a React prop) current without restarting the actor. + * Patches survive a stop/start cycle (e.g. React StrictMode). + */ + setContext: (patch: Partial) => void; + /** + * Re-evaluate the current state against live data. Call this when external data + * a guard reads (an SWR cache, a store) has changed, so the machine can + * self-correct: + * - if the *current* state's entry guard no longer holds, re-seat to the + * freshly resolved initial state (e.g. the Wizard's furthest-reachable step); + * - otherwise re-run the current state's `always` transitions, resolving one + * that was parked waiting on the now-arrived data. + * A no-op (no notify) when the current state is still enterable and no `always` + * transition applies. + */ + recheck: () => void; +} + +export interface CreateActorOptions { + /** + * Runtime context merged over machine defaults at actor creation time. + * Use this to inject dependencies (e.g. an async function from a hook) + * without putting them in the module-level machine definition. + * Snapshot context takes precedence when both are provided. + */ + context?: Partial; + /** + * Start the actor teleported to this snapshot instead of the machine's + * initial state. Used by {@link mockActor} — the actor is inert (no entry + * actions, immediates, or invokes run for the teleported state). + */ + snapshot?: { value: string; context?: Partial }; +} diff --git a/packages/ui/src/mosaic/machine/useMachine.ts b/packages/ui/src/mosaic/machine/useMachine.ts new file mode 100644 index 00000000000..cc49d551e14 --- /dev/null +++ b/packages/ui/src/mosaic/machine/useMachine.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react'; + +import { createActor } from './createActor'; +import type { Actor, CreateActorOptions, EventObject, Snapshot, StateMachine } from './types'; + +export interface UseMachineOptions extends CreateActorOptions { + /** Called once when the machine reaches a final state (`type: 'final'`). */ + onDone?: () => void; +} + +/** + * Bind an already-created actor to a component. Re-renders on every transition. + * + * Use this for a **shared** actor (one instance several components read from). + * The actor's lifecycle (`start`/`stop`) is the caller's responsibility — this + * hook only subscribes. A {@link mockActor} can be passed directly to render a + * teleported step. + */ +export function useActor( + actor: Actor, +): [Snapshot, Actor['send']] { + const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); + return [snapshot, actor.send]; +} + +/** + * Create-and-own an actor for a machine, started for the component's lifetime. + * + * Convenience wrapper for the common "one component drives one flow" case: + * returns `[snapshot, send]`, starts the actor on mount and stops it on unmount. + * + * ```tsx + * const [snapshot, send] = useMachine(machine); + * return ; + * ``` + */ +export function useMachine( + machine: StateMachine, + options?: UseMachineOptions, +): [Snapshot, Actor['send']] { + const actorRef = useRef | null>(null); + if (actorRef.current === null) { + actorRef.current = createActor(machine, options); + } + const actor = actorRef.current; + + useEffect(() => { + actor.start(); + return () => actor.stop(); + }, [actor]); + + // Keep injected context (e.g. a function from props) current on every render. + // useLayoutEffect with no deps runs synchronously after every render, before + // paint — ensuring setContext fires before any user event triggers an invoke. + useLayoutEffect(() => { + if (options?.context) actor.setContext(options.context); + }); + + const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); + + const onDoneRef = useRef(options?.onDone); + onDoneRef.current = options?.onDone; + useEffect(() => { + if (snapshot.status === 'done') onDoneRef.current?.(); + }, [snapshot.status]); + + return [snapshot, actor.send]; +} + +/** + * Subscribe to a memoised slice of an actor's snapshot. The component only + * re-renders when the *selected* projection changes (per `equals`, default + * `Object.is`) — not on every transition. + * + * This is the primary way to consume a shared actor; reach for it whenever a + * component cares about one field rather than the whole snapshot. + * + * ```ts + * const error = useSelector(actor, snap => snap.context.error); + * ``` + */ +export function useSelector( + actor: Actor, + selector: (snapshot: Snapshot) => TSelected, + equals: (a: TSelected, b: TSelected) => boolean = Object.is, +): TSelected { + const selectorRef = useRef(selector); + const equalsRef = useRef(equals); + selectorRef.current = selector; + equalsRef.current = equals; + + const selectedRef = useRef<{ value: TSelected } | null>(null); + + const getSelection = useCallback(() => { + const next = selectorRef.current(actor.getSnapshot()); + const previous = selectedRef.current; + if (previous !== null && equalsRef.current(previous.value, next)) { + // Return the cached reference so useSyncExternalStore bails out of the render. + return previous.value; + } + selectedRef.current = { value: next }; + return next; + }, [actor]); + + return useSyncExternalStore(actor.subscribe, getSelection, getSelection); +} + +/** + * Logs machine state transitions to the console. Drop it directly below a + * `useMachine` or `useActor` call; remove before shipping. + * + * ```ts + * const [snapshot, send] = useMachine(machine); + * useMachineLogger('deleteOrg', snapshot); + * ``` + * + * Output: `[deleteOrg] idle → confirming {error: null}` + */ +export function useMachineLogger(label: string, snapshot: Snapshot): void { + const prevValue = useRef(undefined); + + useEffect(() => { + const current = snapshot.value; + const previous = prevValue.current; + prevValue.current = current; + + if (previous === undefined) { + console.log(`[${label}] ${current}`, snapshot.context); + } else if (previous !== current) { + console.log(`[${label}] ${previous} → ${current}`, snapshot.context); + } + }); +} diff --git a/packages/ui/src/mosaic/machines/__tests__/firstFactorMachine.test.ts b/packages/ui/src/mosaic/machines/__tests__/firstFactorMachine.test.ts new file mode 100644 index 00000000000..9ced9a901be --- /dev/null +++ b/packages/ui/src/mosaic/machines/__tests__/firstFactorMachine.test.ts @@ -0,0 +1,229 @@ +import type { SignInFirstFactor, SignInResource } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { createFirstFactorMachine } from '../firstFactorMachine'; +import { deferred, noop, tick } from './test-utils'; + +const passwordFactor = { strategy: 'password' } as SignInFirstFactor; +const emailCodeFactor = { + strategy: 'email_code', + emailAddressId: 'ea_1', + safeIdentifier: 'a**@e**.com', +} as SignInFirstFactor; +const phoneCodeFactor = { + strategy: 'phone_code', + phoneNumberId: 'pn_1', + safeIdentifier: '+1***5678', +} as SignInFirstFactor; + +function makeActor(overrides: Partial[0]> = {}) { + const actor = createActor( + createFirstFactorMachine({ + factor: passwordFactor, + attemptFn: noop as never, + prepareFn: noop as never, + ...overrides, + }), + ); + actor.start(); + return actor; +} + +describe('firstFactorMachine — initial state', () => { + it('starts in verifying for password factor', () => { + expect(makeActor({ factor: passwordFactor }).getSnapshot().value).toBe('verifying'); + }); + + it('starts in preparing for email_code factor', () => { + const gate = deferred(); + const actor = makeActor({ factor: emailCodeFactor, prepareFn: () => gate.promise }); + expect(actor.getSnapshot().value).toBe('preparing'); + }); + + it('starts in preparing for phone_code factor', () => { + const gate = deferred(); + const actor = makeActor({ factor: phoneCodeFactor, prepareFn: () => gate.promise }); + expect(actor.getSnapshot().value).toBe('preparing'); + }); + + it('moves to verifying once prepare resolves', async () => { + const actor = makeActor({ + factor: emailCodeFactor, + prepareFn: vi.fn().mockResolvedValue({ status: 'needs_first_factor' } as SignInResource), + }); + await tick(); + expect(actor.getSnapshot().value).toBe('verifying'); + }); + + it('moves to verifying (with error) if prepare fails', async () => { + const actor = makeActor({ + factor: emailCodeFactor, + prepareFn: vi.fn().mockRejectedValue(new Error('Delivery failed.')), + }); + await tick(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.error).toBe('Error: Delivery failed.'); + }); + + it('stores factor strategy in context', () => { + expect(makeActor({ factor: emailCodeFactor }).getSnapshot().context.factor.strategy).toBe('email_code'); + }); +}); + +describe('firstFactorMachine — typing', () => { + it('updates value in context on TYPE', () => { + const actor = makeActor(); + actor.send({ type: 'TYPE', value: 'mysecret' }); + expect(actor.getSnapshot().context.value).toBe('mysecret'); + }); + + it('clears error on TYPE', async () => { + const actor = makeActor({ attemptFn: vi.fn().mockRejectedValue(new Error('Wrong password.')) }); + actor.send({ type: 'TYPE', value: 'wrong' }); + actor.send({ type: 'SUBMIT' }); + await tick(); + + actor.send({ type: 'TYPE', value: 'corrected' }); + expect(actor.getSnapshot().context.error).toBeNull(); + }); +}); + +describe('firstFactorMachine — submission', () => { + it('moves to submitting on SUBMIT', () => { + const gate = deferred(); + const actor = makeActor({ attemptFn: () => gate.promise }); + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot().value).toBe('submitting'); + }); + + it('reaches complete (final) on success', async () => { + const actor = makeActor({ attemptFn: vi.fn().mockResolvedValue({ status: 'complete' } as SignInResource) }); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('complete'); + expect(actor.getSnapshot().status).toBe('done'); + }); + + it('stores completionStatus in context so parent can route on it', async () => { + const actor = makeActor({ + attemptFn: vi.fn().mockResolvedValue({ status: 'needs_second_factor' } as SignInResource), + }); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().context.completionStatus).toBe('needs_second_factor'); + }); + + it('returns to verifying with error on failure', async () => { + const actor = makeActor({ attemptFn: vi.fn().mockRejectedValue(new Error('Wrong password.')) }); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.error).toBe('Error: Wrong password.'); + }); + + it('cannot SUBMIT from showingAlternatives — impossible state', () => { + const actor = makeActor(); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + const before = actor.getSnapshot(); + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot()).toBe(before); // same reference — no transition + }); +}); + +describe('firstFactorMachine — alternatives overlay', () => { + it('opens on SHOW_ALTERNATIVES', () => { + const actor = makeActor(); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + expect(actor.getSnapshot().value).toBe('showingAlternatives'); + }); + + it('returns to verifying on BACK', () => { + const actor = makeActor(); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + actor.send({ type: 'BACK' }); + expect(actor.getSnapshot().value).toBe('verifying'); + }); + + it('switches to verifying when selecting password factor', () => { + // Start with default password factor (already in verifying), open alternatives, switch to password again. + const actor = makeActor(); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + actor.send({ type: 'SELECT_STRATEGY', factor: passwordFactor }); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.factor.strategy).toBe('password'); + }); + + it('switches to preparing when selecting a code-based factor', () => { + // Start in verifying (password factor), switch to email_code — should enter preparing. + const gate = deferred(); + const actor = makeActor({ prepareFn: () => gate.promise }); + actor.send({ type: 'SHOW_ALTERNATIVES' }); + actor.send({ type: 'SELECT_STRATEGY', factor: emailCodeFactor }); + expect(actor.getSnapshot().value).toBe('preparing'); + expect(actor.getSnapshot().context.factor.strategy).toBe('email_code'); + }); +}); + +describe('firstFactorMachine — forgot password overlay', () => { + it('opens on SHOW_FORGOT_PASSWORD', () => { + const actor = makeActor(); + actor.send({ type: 'SHOW_FORGOT_PASSWORD' }); + expect(actor.getSnapshot().value).toBe('showingForgotPassword'); + }); + + it('returns to verifying on BACK', () => { + const actor = makeActor(); + actor.send({ type: 'SHOW_FORGOT_PASSWORD' }); + actor.send({ type: 'BACK' }); + expect(actor.getSnapshot().value).toBe('verifying'); + }); + + it('switches to preparing when selecting a reset code strategy', () => { + const gate = deferred(); + const resetFactor = { strategy: 'reset_password_email_code', emailAddressId: 'ea_1' } as SignInFirstFactor; + const actor = makeActor({ prepareFn: () => gate.promise }); + actor.send({ type: 'SHOW_FORGOT_PASSWORD' }); + actor.send({ type: 'SELECT_STRATEGY', factor: resetFactor }); + expect(actor.getSnapshot().value).toBe('preparing'); + expect(actor.getSnapshot().context.factor.strategy).toBe('reset_password_email_code'); + }); +}); + +describe('firstFactorMachine — resend + cooldown', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('moves through resending → cooldown → verifying', async () => { + const actor = makeActor({ prepareFn: vi.fn().mockResolvedValue({} as SignInResource) }); + + actor.send({ type: 'RESEND' }); + expect(actor.getSnapshot().value).toBe('resending'); + + await vi.runAllTicks(); + expect(actor.getSnapshot().value).toBe('cooldown'); + expect(actor.getSnapshot().context.canResend).toBe(false); + + vi.advanceTimersByTime(30_000); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.canResend).toBe(true); + }); + + it('cannot RESEND during cooldown', async () => { + const actor = makeActor({ prepareFn: vi.fn().mockResolvedValue({} as SignInResource) }); + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); // → cooldown + + const before = actor.getSnapshot(); + actor.send({ type: 'RESEND' }); + expect(actor.getSnapshot()).toBe(before); + }); + + it('returns to verifying with error if resend fails', async () => { + const actor = makeActor({ prepareFn: vi.fn().mockRejectedValue(new Error('Rate limited.')) }); + actor.send({ type: 'RESEND' }); + await vi.runAllTicks(); + expect(actor.getSnapshot().value).toBe('verifying'); + expect(actor.getSnapshot().context.error).toBe('Error: Rate limited.'); + }); +}); diff --git a/packages/ui/src/mosaic/machines/__tests__/signInMachine.test.ts b/packages/ui/src/mosaic/machines/__tests__/signInMachine.test.ts new file mode 100644 index 00000000000..39f00706d82 --- /dev/null +++ b/packages/ui/src/mosaic/machines/__tests__/signInMachine.test.ts @@ -0,0 +1,238 @@ +import type { SignInResource } from '@clerk/shared/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { createSignInMachine } from '../signInMachine'; +import { deferred, noop, tick } from './test-utils'; + +const makeAttempt = (status: string) => vi.fn().mockResolvedValue({ status } as SignInResource); + +describe('signInMachine — initial state', () => { + it('starts in collectingIdentifier', () => { + const actor = createActor(createSignInMachine({ createAttemptFn: noop as never, resetPasswordFn: noop as never })); + actor.start(); + expect(actor.getSnapshot().value).toBe('collectingIdentifier'); + }); + + it('exposes every step as a named state — the whole flow is readable at a glance', () => { + const machine = createSignInMachine({ createAttemptFn: noop as never, resetPasswordFn: noop as never }); + expect(Object.keys(machine.states)).toEqual([ + 'collectingIdentifier', + 'submittingIdentifier', + 'routingIdentifier', + 'firstFactor', + 'secondFactor', + 'clientTrust', + 'resetPassword', + 'submittingResetPassword', + 'routingReset', + 'resetPasswordSuccess', + 'complete', + ]); + }); +}); + +describe('signInMachine — identifier collection', () => { + it('updates identifier in context on TYPE_IDENTIFIER', () => { + const actor = createActor(createSignInMachine({ createAttemptFn: noop as never, resetPasswordFn: noop as never })); + actor.start(); + actor.send({ type: 'TYPE_IDENTIFIER', value: 'alex@example.com' }); + expect(actor.getSnapshot().context.identifier).toBe('alex@example.com'); + }); + + it('clears error when typing', async () => { + const actor = createActor( + createSignInMachine({ + createAttemptFn: vi.fn().mockRejectedValue(new Error('bad')), + resetPasswordFn: noop as never, + }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + + actor.send({ type: 'TYPE_IDENTIFIER', value: 'alex@example.com' }); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('moves to submittingIdentifier on SUBMIT', () => { + const gate = deferred<{ status: string }>(); + const actor = createActor( + createSignInMachine({ createAttemptFn: () => gate.promise as never, resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + expect(actor.getSnapshot().value).toBe('submittingIdentifier'); + }); +}); + +describe('signInMachine — identifier submission branches', () => { + it('routes to firstFactor on needs_first_factor', async () => { + const actor = createActor( + createSignInMachine({ createAttemptFn: makeAttempt('needs_first_factor'), resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('firstFactor'); + }); + + it('routes to secondFactor on needs_second_factor', async () => { + const actor = createActor( + createSignInMachine({ createAttemptFn: makeAttempt('needs_second_factor'), resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('secondFactor'); + }); + + it('routes to clientTrust on needs_client_trust', async () => { + const actor = createActor( + createSignInMachine({ createAttemptFn: makeAttempt('needs_client_trust'), resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('clientTrust'); + }); + + it('routes to resetPassword on needs_new_password (forced reset flow)', async () => { + const actor = createActor( + createSignInMachine({ createAttemptFn: makeAttempt('needs_new_password'), resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('resetPassword'); + }); + + it('reaches complete (final) on status complete', async () => { + const actor = createActor( + createSignInMachine({ createAttemptFn: makeAttempt('complete'), resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('complete'); + expect(actor.getSnapshot().status).toBe('done'); + }); + + it('returns to collectingIdentifier with error on failure', async () => { + const actor = createActor( + createSignInMachine({ + createAttemptFn: vi.fn().mockRejectedValue(new Error('Identifier is invalid.')), + resetPasswordFn: noop as never, + }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + expect(actor.getSnapshot().value).toBe('collectingIdentifier'); + expect(actor.getSnapshot().context.error).toBe('Error: Identifier is invalid.'); + }); +}); + +describe('signInMachine — firstFactor handoff', () => { + async function inFirstFactor() { + const actor = createActor( + createSignInMachine({ createAttemptFn: makeAttempt('needs_first_factor'), resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + return actor; + } + + it('advances to complete on FACTOR_COMPLETE with status complete', async () => { + const actor = await inFirstFactor(); + actor.send({ type: 'FACTOR_COMPLETE', nextStatus: 'complete' }); + expect(actor.getSnapshot().value).toBe('complete'); + expect(actor.getSnapshot().status).toBe('done'); + }); + + it('advances to secondFactor on FACTOR_COMPLETE with needs_second_factor', async () => { + const actor = await inFirstFactor(); + actor.send({ type: 'FACTOR_COMPLETE', nextStatus: 'needs_second_factor' }); + expect(actor.getSnapshot().value).toBe('secondFactor'); + }); + + it('advances to resetPassword on FACTOR_COMPLETE with needs_new_password', async () => { + const actor = await inFirstFactor(); + actor.send({ type: 'FACTOR_COMPLETE', nextStatus: 'needs_new_password' }); + expect(actor.getSnapshot().value).toBe('resetPassword'); + }); + + it('goes to resetPassword on FORGOT_PASSWORD', async () => { + const actor = await inFirstFactor(); + actor.send({ type: 'FORGOT_PASSWORD' }); + expect(actor.getSnapshot().value).toBe('resetPassword'); + }); + + it('returns to collectingIdentifier on BACK', async () => { + const actor = await inFirstFactor(); + actor.send({ type: 'BACK' }); + expect(actor.getSnapshot().value).toBe('collectingIdentifier'); + }); +}); + +describe('signInMachine — secondFactor handoff', () => { + it('reaches complete on FACTOR_COMPLETE', async () => { + const actor = createActor( + createSignInMachine({ createAttemptFn: makeAttempt('needs_second_factor'), resetPasswordFn: noop as never }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + actor.send({ type: 'FACTOR_COMPLETE', nextStatus: 'complete' }); + expect(actor.getSnapshot().value).toBe('complete'); + }); +}); + +describe('signInMachine — reset password flow', () => { + async function inResetPassword( + resetPasswordFn: (p: { password: string; signOutOfOtherSessions?: boolean }) => Promise<{ status: string }>, + ) { + const actor = createActor( + createSignInMachine({ + createAttemptFn: makeAttempt('needs_first_factor'), + resetPasswordFn: resetPasswordFn as never, + }), + ); + actor.start(); + actor.send({ type: 'SUBMIT' }); + await tick(); + actor.send({ type: 'FORGOT_PASSWORD' }); + return actor; + } + + it('reaches resetPasswordSuccess on success', async () => { + const actor = await inResetPassword(makeAttempt('complete')); + actor.send({ type: 'SUBMIT_NEW_PASSWORD', password: 'hunter2', signOutOfOtherSessions: true }); + await tick(); + expect(actor.getSnapshot().value).toBe('resetPasswordSuccess'); + }); + + it('routes to secondFactor if reset requires MFA', async () => { + const actor = await inResetPassword(makeAttempt('needs_second_factor')); + actor.send({ type: 'SUBMIT_NEW_PASSWORD', password: 'hunter2', signOutOfOtherSessions: false }); + await tick(); + expect(actor.getSnapshot().value).toBe('secondFactor'); + }); + + it('stays in resetPassword with error on failure', async () => { + const actor = await inResetPassword(vi.fn().mockRejectedValue(new Error('Password too weak.'))); + actor.send({ type: 'SUBMIT_NEW_PASSWORD', password: 'abc', signOutOfOtherSessions: false }); + await tick(); + expect(actor.getSnapshot().value).toBe('resetPassword'); + expect(actor.getSnapshot().context.error).toBe('Error: Password too weak.'); + }); + + it('passes signOutOfOtherSessions to resetPasswordFn', async () => { + const resetMock = vi.fn().mockResolvedValue({ status: 'complete' } as SignInResource); + const actor = await inResetPassword(resetMock); + actor.send({ type: 'SUBMIT_NEW_PASSWORD', password: 'newpass', signOutOfOtherSessions: true }); + await tick(); + expect(resetMock).toHaveBeenCalledWith({ password: 'newpass', signOutOfOtherSessions: true }); + }); +}); diff --git a/packages/ui/src/mosaic/machines/__tests__/test-utils.ts b/packages/ui/src/mosaic/machines/__tests__/test-utils.ts new file mode 100644 index 00000000000..a7b60e99b65 --- /dev/null +++ b/packages/ui/src/mosaic/machines/__tests__/test-utils.ts @@ -0,0 +1,20 @@ +/** + * Shared test utilities for machine tests. + */ + +/** A deferred promise — resolve/reject captured outside the promise executor. */ +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Yields to the event loop, allowing microtasks (invoke onDone/onError) to settle. */ +export const tick = () => new Promise(r => setTimeout(r, 0)); + +/** No-op async function for dependency injection in tests. */ +export const noop = async () => {}; diff --git a/packages/ui/src/mosaic/machines/firstFactorMachine.ts b/packages/ui/src/mosaic/machines/firstFactorMachine.ts new file mode 100644 index 00000000000..cbd65282a2d --- /dev/null +++ b/packages/ui/src/mosaic/machines/firstFactorMachine.ts @@ -0,0 +1,181 @@ +import type { + AttemptFirstFactorParams, + PrepareFirstFactorParams, + SignInFirstFactor, + SignInResource, + SignInStatus, +} from '@clerk/shared/types'; + +import { setup } from '../machine/setup'; + +export interface FirstFactorContext { + factor: SignInFirstFactor; + value: string; + error: string | null; + canResend: boolean; + completionStatus: SignInStatus | null; + // Injected deps + attemptFn: (params: AttemptFirstFactorParams) => Promise; + prepareFn: (params: PrepareFirstFactorParams) => Promise; +} + +export type FirstFactorEvent = + | { type: 'TYPE'; value: string } + | { type: 'SUBMIT' } + | { type: 'SHOW_ALTERNATIVES' } + | { type: 'SHOW_FORGOT_PASSWORD' } + | { type: 'SELECT_STRATEGY'; factor: SignInFirstFactor } + | { type: 'RESEND' } + | { type: 'BACK' }; + +const { createMachine, assign, fromPromise } = setup(); + +// Strategies that require a prepare call before showing the code input. +// password, passkey, and social/enterprise strategies are excluded. +const PREPARE_STRATEGIES = new Set([ + 'email_code', + 'phone_code', + 'email_link', + 'reset_password_email_code', + 'reset_password_phone_code', +]); + +const needsPrepare = (factor: SignInFirstFactor): boolean => PREPARE_STRATEGIES.has(factor.strategy); + +// Build the strategy-specific attempt params from the current factor and entered value. +function buildAttemptParams(factor: SignInFirstFactor, value: string): AttemptFirstFactorParams { + if (factor.strategy === 'password') { + return { strategy: 'password', password: value }; + } + // Code-based strategies (email_code, phone_code, reset_password_*_code) all use `code`. + return { strategy: factor.strategy, code: value } as AttemptFirstFactorParams; +} + +/** + * Models the UI modes within the first-factor verification screen. + * + * The initial state is derived from the factor: code-based strategies (email_code, + * phone_code, reset_password_*) start in `preparing` to trigger a prepareFirstFactor + * call before showing the input. Password goes straight to `verifying`. + * + * Replaces scattered boolean flags (`showAllStrategies`, `showForgotPasswordStrategies`, + * `passwordErrorCode`, `card.isLoading`) with named states. Impossible combinations + * — like submitting while the alternatives overlay is open — become unrepresentable. + * + * On completion, `context.completionStatus` carries the signIn.status so the + * parent machine can route to secondFactor, resetPassword, or complete: + * + * const [snapshot, send] = useMachine(firstFactorMachine, { + * context: { factor, attemptFn: signIn.attemptFirstFactor, prepareFn: signIn.prepareFirstFactor }, + * onDone: () => parentSend({ type: 'FACTOR_COMPLETE', nextStatus: snapshot.context.completionStatus }), + * }); + */ +export function createFirstFactorMachine(deps: { + factor: SignInFirstFactor; + attemptFn: (params: AttemptFirstFactorParams) => Promise; + prepareFn: (params: PrepareFirstFactorParams) => Promise; +}) { + return createMachine({ + id: 'firstFactor', + // Code-based strategies must prepare before showing the input. + initial: ctx => (needsPrepare(ctx.factor) ? 'preparing' : 'verifying'), + context: { + factor: deps.factor, + value: '', + error: null, + canResend: true, + completionStatus: null, + attemptFn: deps.attemptFn, + prepareFn: deps.prepareFn, + }, + states: { + // Calls prepareFirstFactor on entry (delivers the code via email/SMS). + // Cast is safe: only entered when needsPrepare(ctx.factor) is true. + preparing: { + invoke: fromPromise(ctx => ctx.prepareFn(ctx.factor as PrepareFirstFactorParams), { + onDone: 'verifying', + onError: { + target: 'verifying', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + + verifying: { + on: { + TYPE: { + actions: assign((_, e) => ({ value: e.value, error: null })), + }, + SUBMIT: 'submitting', + SHOW_ALTERNATIVES: 'showingAlternatives', + SHOW_FORGOT_PASSWORD: 'showingForgotPassword', + RESEND: 'resending', + }, + }, + + submitting: { + invoke: fromPromise(ctx => ctx.attemptFn(buildAttemptParams(ctx.factor, ctx.value)), { + onDone: { + target: 'complete', + actions: assign((_, e) => ({ completionStatus: e.output.status as SignInStatus })), + }, + onError: { + target: 'verifying', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + + // After selecting a strategy, route to preparing (for code) or verifying (for password). + routingStrategy: { + always: [{ target: 'preparing', guard: ctx => needsPrepare(ctx.factor) }, { target: 'verifying' }], + }, + + showingAlternatives: { + on: { + SELECT_STRATEGY: { + target: 'routingStrategy', + actions: assign((_, e) => ({ factor: e.factor, value: '', error: null })), + }, + BACK: 'verifying', + }, + }, + + showingForgotPassword: { + on: { + SELECT_STRATEGY: { + target: 'routingStrategy', + actions: assign((_, e) => ({ factor: e.factor, value: '', error: null })), + }, + BACK: 'verifying', + }, + }, + + // Re-delivers the code by calling prepareFirstFactor again, then starts the cooldown. + resending: { + invoke: fromPromise(ctx => ctx.prepareFn(ctx.factor as PrepareFirstFactorParams), { + onDone: { + target: 'cooldown', + actions: assign(() => ({ canResend: false, value: '' })), + }, + onError: { + target: 'verifying', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + + // Timer lives in the machine — no useEffect + setTimeout in the component. + cooldown: { + after: { + 30_000: { + target: 'verifying', + actions: assign(() => ({ canResend: true })), + }, + }, + }, + + complete: { type: 'final' }, + }, + }); +} diff --git a/packages/ui/src/mosaic/machines/signInMachine.ts b/packages/ui/src/mosaic/machines/signInMachine.ts new file mode 100644 index 00000000000..17f1ada33f6 --- /dev/null +++ b/packages/ui/src/mosaic/machines/signInMachine.ts @@ -0,0 +1,171 @@ +import type { ResetPasswordParams, SignInResource, SignInStatus } from '@clerk/shared/types'; + +import { setup } from '../machine/setup'; + +export interface SignInContext { + identifier: string; + pendingPassword: string; + pendingSignOutOfOtherSessions: boolean; + pendingStatus: string; + error: string | null; + // Injected deps — passed via useMachine options.context so they're always current. + createAttemptFn: (identifier: string) => Promise; + resetPasswordFn: (params: ResetPasswordParams) => Promise; +} + +export type SignInEvent = + | { type: 'TYPE_IDENTIFIER'; value: string } + | { type: 'SUBMIT' } + | { type: 'FACTOR_COMPLETE'; nextStatus: SignInStatus } + | { type: 'FORGOT_PASSWORD' } + | { type: 'SUBMIT_NEW_PASSWORD'; password: string; signOutOfOtherSessions: boolean } + | { type: 'BACK' }; + +const { createMachine, assign, fromPromise } = setup(); + +/** + * Models the top-level Clerk sign-in routing as an explicit state machine. + * + * Each named state corresponds to a distinct screen or async step. The entire + * flow — identifier entry, first/second factor, password reset — is visible in + * one object with no hidden boolean flags. + * + * Component usage: + * const [snapshot, send] = useMachine(machine, { + * context: { createAttemptFn: id => signIn.create({ identifier: id }), resetPasswordFn: signIn.resetPassword }, + * onDone: () => setActive({ session: signIn.createdSessionId }).then(() => router.navigate(afterSignInUrl)), + * }); + * + * Child factor components signal completion by calling: + * send({ type: 'FACTOR_COMPLETE', nextStatus: signIn.status }) + */ +export function createSignInMachine(deps: Pick) { + return createMachine({ + id: 'signIn', + initial: 'collectingIdentifier', + context: { + identifier: '', + pendingPassword: '', + pendingSignOutOfOtherSessions: false, + pendingStatus: '', + error: null, + createAttemptFn: deps.createAttemptFn, + resetPasswordFn: deps.resetPasswordFn, + }, + states: { + collectingIdentifier: { + on: { + TYPE_IDENTIFIER: { + actions: assign((_, e) => ({ identifier: e.value, error: null })), + }, + SUBMIT: 'submittingIdentifier', + }, + }, + + submittingIdentifier: { + invoke: fromPromise(ctx => ctx.createAttemptFn(ctx.identifier), { + onDone: { + target: 'routingIdentifier', + actions: assign((_, e) => ({ pendingStatus: e.output.status })), + }, + onError: { + target: 'collectingIdentifier', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }), + }, + + // Transient state — `always` transitions fire synchronously so callers see the resolved state. + routingIdentifier: { + always: [ + { target: 'firstFactor', guard: ctx => ctx.pendingStatus === 'needs_first_factor' }, + { target: 'secondFactor', guard: ctx => ctx.pendingStatus === 'needs_second_factor' }, + { target: 'clientTrust', guard: ctx => ctx.pendingStatus === 'needs_client_trust' }, + { target: 'resetPassword', guard: ctx => ctx.pendingStatus === 'needs_new_password' }, + { target: 'complete' }, + ], + }, + + // Child factor component owns its own machine. When it reaches `complete`, + // it calls onDone which sends FACTOR_COMPLETE with the resulting signIn.status. + firstFactor: { + on: { + FACTOR_COMPLETE: [ + { target: 'secondFactor', guard: (_, e) => e.nextStatus === 'needs_second_factor' }, + { target: 'clientTrust', guard: (_, e) => e.nextStatus === 'needs_client_trust' }, + { target: 'resetPassword', guard: (_, e) => e.nextStatus === 'needs_new_password' }, + { target: 'complete' }, + ], + FORGOT_PASSWORD: 'resetPassword', + BACK: 'collectingIdentifier', + }, + }, + + secondFactor: { + on: { + FACTOR_COMPLETE: 'complete', + BACK: 'firstFactor', + }, + }, + + clientTrust: { + on: { + FACTOR_COMPLETE: 'complete', + BACK: 'firstFactor', + }, + }, + + // Shown when the user clicks "Forgot password" from firstFactor, or when + // signIn.status is 'needs_new_password' after a reset code is verified. + resetPassword: { + on: { + SUBMIT_NEW_PASSWORD: { + target: 'submittingResetPassword', + actions: assign((_, e) => ({ + pendingPassword: e.password, + pendingSignOutOfOtherSessions: e.signOutOfOtherSessions, + error: null, + })), + }, + BACK: 'firstFactor', + }, + }, + + submittingResetPassword: { + invoke: fromPromise( + ctx => + ctx.resetPasswordFn({ + password: ctx.pendingPassword, + signOutOfOtherSessions: ctx.pendingSignOutOfOtherSessions, + }), + { + onDone: { + target: 'routingReset', + actions: assign((_, e) => ({ pendingStatus: e.output.status })), + }, + onError: { + target: 'resetPassword', + actions: assign((_, e) => ({ error: String(e.error) })), + }, + }, + ), + }, + + routingReset: { + always: [ + { target: 'secondFactor', guard: ctx => ctx.pendingStatus === 'needs_second_factor' }, + { target: 'resetPasswordSuccess' }, + ], + }, + + resetPasswordSuccess: { + // Brief confirmation screen; the component calls onDone to fire navigation. + type: 'final', + }, + + complete: { + type: 'final', + }, + }, + }); +} diff --git a/packages/ui/src/mosaic/sections/delete-organization-machine.ts b/packages/ui/src/mosaic/sections/delete-organization-machine.ts new file mode 100644 index 00000000000..f665beb6720 --- /dev/null +++ b/packages/ui/src/mosaic/sections/delete-organization-machine.ts @@ -0,0 +1,41 @@ +import { assign } from '../machine/assign'; +import { createMachine } from '../machine/createMachine'; +import type { ErrorInvokeEvent } from '../machine/types'; + +export interface DeleteOrgContext { + destroyFn: () => Promise; + error: string | null; +} + +export type DeleteOrgEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' }; + +export const deleteOrgMachine = createMachine({ + id: 'deleteOrg', + initial: 'idle', + context: { destroyFn: async () => {}, error: null }, + states: { + idle: { on: { OPEN: 'confirming' } }, + confirming: { + on: { + CONFIRM: 'deleting', + CANCEL: { + target: 'idle', + actions: assign(() => ({ error: null })), + }, + }, + }, + deleting: { + invoke: { + src: ctx => ctx.destroyFn(), + onDone: 'deleted', + onError: { + target: 'confirming', + actions: assign((_, event) => ({ + error: String(event.error), + })), + }, + }, + }, + deleted: { type: 'final' }, + }, +}); diff --git a/packages/ui/src/mosaic/sections/delete-organization.tsx b/packages/ui/src/mosaic/sections/delete-organization.tsx index d17ce81346f..ef4fae6009d 100644 --- a/packages/ui/src/mosaic/sections/delete-organization.tsx +++ b/packages/ui/src/mosaic/sections/delete-organization.tsx @@ -1,26 +1,20 @@ -import { useState } from 'react'; - import { Box } from '../components/box'; import { Button } from '../components/button'; import { SectionSkeleton } from '../components/section-skeleton'; import { Destructive } from '../block/destructive'; import { useOrganization } from '../mock/use-organization'; +import { useMachine } from '../machine/useMachine'; +import type { MockOrganization } from '../mock/use-organization'; +import { deleteOrgMachine } from './delete-organization-machine'; export function DeleteOrganization() { const { isLoaded, organization } = useOrganization(); - const [open, setOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - if (!isLoaded || !organization) { - return ; - } + if (!isLoaded || !organization) return ; + return ; +} - const handleDelete = async () => { - setIsDeleting(true); - await organization.destroy(); - setIsDeleting(false); - setOpen(false); - }; +function DeleteOrganizationReady({ organization }: { organization: MockOrganization }) { + const [snapshot, send] = useMachine(deleteOrgMachine, { context: { destroyFn: () => organization.destroy() } }); return ( )} - open={open} - onOpenChange={setOpen} + open={snapshot.value === 'confirming' || snapshot.value === 'deleting'} + onOpenChange={isOpen => send({ type: isOpen ? 'OPEN' : 'CANCEL' })} title='Delete organization' description='Are you sure you want to delete this organization?' resourceName={organization.name} primaryActionLabel='Delete organization' - onDelete={handleDelete} - isDeleting={isDeleting} + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'deleting'} /> diff --git a/packages/ui/src/mosaic/sections/leave-organization-machine.ts b/packages/ui/src/mosaic/sections/leave-organization-machine.ts new file mode 100644 index 00000000000..464a0f5c10e --- /dev/null +++ b/packages/ui/src/mosaic/sections/leave-organization-machine.ts @@ -0,0 +1,41 @@ +import { assign } from '../machine/assign'; +import { createMachine } from '../machine/createMachine'; +import type { ErrorInvokeEvent } from '../machine/types'; + +export interface LeaveOrgContext { + leaveFn: () => Promise; + error: string | null; +} + +export type LeaveOrgEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' }; + +export const leaveOrgMachine = createMachine({ + id: 'leaveOrg', + initial: 'idle', + context: { leaveFn: async () => {}, error: null }, + states: { + idle: { on: { OPEN: 'confirming' } }, + confirming: { + on: { + CONFIRM: 'leaving', + CANCEL: { + target: 'idle', + actions: assign(() => ({ error: null })), + }, + }, + }, + leaving: { + invoke: { + src: ctx => ctx.leaveFn(), + onDone: 'left', + onError: { + target: 'confirming', + actions: assign((_, event) => ({ + error: String(event.error), + })), + }, + }, + }, + left: { type: 'final' }, + }, +}); diff --git a/packages/ui/src/mosaic/sections/leave-organization.tsx b/packages/ui/src/mosaic/sections/leave-organization.tsx index d78e0d6175e..8313f14af47 100644 --- a/packages/ui/src/mosaic/sections/leave-organization.tsx +++ b/packages/ui/src/mosaic/sections/leave-organization.tsx @@ -1,92 +1,95 @@ -import { useState } from 'react'; - import { Box } from '../components/box'; import { Button } from '../components/button'; import { SectionSkeleton } from '../components/section-skeleton'; import { Destructive } from '../block/destructive'; import { useOrganization } from '../mock/use-organization'; +import { useMachine } from '../machine/useMachine'; +import type { MockMembership, MockOrganization } from '../mock/use-organization'; +import { leaveOrgMachine } from './leave-organization-machine'; export function LeaveOrganization() { const { isLoaded, organization, membership } = useOrganization(); - const [open, setOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - if (!isLoaded || !organization || !membership) { - return ; - } + if (!isLoaded || !organization || !membership) return ; + return ( + + ); +} - const handleLeave = async () => { - setIsDeleting(true); - await membership.destroy(); - setIsDeleting(false); - setOpen(false); - }; +function LeaveOrganizationReady({ + organization, + membership, +}: { + organization: MockOrganization; + membership: MockMembership; +}) { + const [snapshot, send] = useMachine(leaveOrgMachine, { context: { leaveFn: () => membership.destroy() } }); return ( - <> + ({ + width: '100%', + containerType: 'inline-size', + })} + > ({ - width: '100%', - containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + columnGap: t.spacing(10), + rowGap: t.spacing(4), + '@container (min-width: 600px)': { + flexDirection: 'row', + }, })} > - ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - columnGap: t.spacing(10), - rowGap: t.spacing(4), - '@container (min-width: 600px)': { - flexDirection: 'row', - }, - })} - > - -

} - sx={t => ({ - ...t.text('base'), - fontWeight: t.font.semibold, - })} - > - Leave organization - -

} - sx={t => ({ - ...t.text('sm'), - textWrap: 'balance', - marginBlockStart: t.spacing(1), - color: t.color.mutedForeground, - })} - > - You will be removed from the organization and need to be invited back - + +

} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.semibold, + })} + > + Leave organization + +

} + sx={t => ({ + ...t.text('sm'), + textWrap: 'balance', + marginBlockStart: t.spacing(1), + color: t.color.mutedForeground, + })} + > + You will be removed from the organization and need to be invited back - ( - - )} - open={open} - onOpenChange={setOpen} - title='Leave organization' - description='Are you sure you want to leave this organization? You will lose access to this organization and its applications.' - primaryActionLabel='Leave organization' - resourceName={organization.name} - onDelete={handleLeave} - isDeleting={isDeleting} - /> + ( + + )} + open={snapshot.value === 'confirming' || snapshot.value === 'leaving'} + onOpenChange={isOpen => send({ type: isOpen ? 'OPEN' : 'CANCEL' })} + title='Leave organization' + description='Are you sure you want to leave this organization? You will lose access to this organization and its applications.' + resourceName={organization.name} + primaryActionLabel='Leave organization' + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'leaving'} + /> - + ); }