From 4a4733d03e4a193ae13e446fd7aedeff4cc954ba Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 22 Jun 2026 14:32:47 -0400 Subject: [PATCH 1/2] feat(ui): add sign-in flow state machines and adoption guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds signInMachine and firstFactorMachine as concrete examples of the advanced machine patterns: async invocations via fromPromise, delayed transitions via after, and composed machines (signIn drives firstFactor as a child machine). Also lands ADOPTION.md — the migration guide covering the full spectrum from simple open/close flows through to full wizard and async invoke patterns, with before/after comparisons for each. --- .changeset/mosaic-machine-signin.md | 2 + packages/ui/src/mosaic/machine/ADOPTION.md | 825 ++++++++++++++++++ .../__tests__/firstFactorMachine.test.ts | 229 +++++ .../machines/__tests__/signInMachine.test.ts | 238 +++++ .../mosaic/machines/__tests__/test-utils.ts | 20 + .../src/mosaic/machines/firstFactorMachine.ts | 190 ++++ .../ui/src/mosaic/machines/signInMachine.ts | 171 ++++ 7 files changed, 1675 insertions(+) create mode 100644 .changeset/mosaic-machine-signin.md create mode 100644 packages/ui/src/mosaic/machine/ADOPTION.md create mode 100644 packages/ui/src/mosaic/machines/__tests__/firstFactorMachine.test.ts create mode 100644 packages/ui/src/mosaic/machines/__tests__/signInMachine.test.ts create mode 100644 packages/ui/src/mosaic/machines/__tests__/test-utils.ts create mode 100644 packages/ui/src/mosaic/machines/firstFactorMachine.ts create mode 100644 packages/ui/src/mosaic/machines/signInMachine.ts diff --git a/.changeset/mosaic-machine-signin.md b/.changeset/mosaic-machine-signin.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-machine-signin.md @@ -0,0 +1,2 @@ +--- +--- 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/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..500f86bd3e6 --- /dev/null +++ b/packages/ui/src/mosaic/machines/firstFactorMachine.ts @@ -0,0 +1,190 @@ +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 }; + } + // SAFETY: buildAttemptParams is only called from `submitting`, which is only reachable + // via `verifying`. The machine's routing guarantees factor.strategy is 'password' or a + // code-based strategy at this call site. TypeScript cannot narrow through state-entry invariants. + 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). + // SAFETY: `preparing` is only entered when needsPrepare(ctx.factor) is true (see + // the initial resolver and routingStrategy always-transitions). TypeScript cannot + // narrow ctx.factor through state-entry invariants, so the cast is required. + 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', + // SAFETY: e.output is a SignInResource whose .status is always a valid SignInStatus + // string; TypeScript types output as the broad resolved return type and does not + // narrow .status to the SignInStatus literal union automatically. + 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: { + // SAFETY: same invariant as `preparing` — `resending` is only reachable when + // the current factor requires a prepare call (needsPrepare is true). + 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', + }, + }, + }); +} From 0e3016df90fc617b2b8b6d2d8440da7e0bfe4dd1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 22 Jun 2026 14:57:53 -0400 Subject: [PATCH 2/2] fix(ui): resolve all lint violations in machine core and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createActor: eslint --fix for 22 curly violations and import sort - types.test-d.ts: disable unbound-method for setup() destructuring (setup returns closures, not this-bound methods — false positive) - test files: remove unnecessary async from functions that don't await, convert async () => value to () => Promise.resolve(value), and add type="button" to all bare - - + + + ); } @@ -47,7 +62,7 @@ describe('useMachine — drives a flow from a component', () => { fireEvent.click(screen.getByText('Confirm')); expect(screen.getByTestId('state')).toHaveTextContent('deleting'); - await act(async () => { + await act(() => { gate.resolve(); }); expect(screen.getByTestId('state')).toHaveTextContent('deleted'); @@ -222,7 +237,12 @@ describe('useMachine — onDone', () => { return (
{snapshot.value} - +
); } @@ -231,7 +251,9 @@ describe('useMachine — onDone', () => { expect(onDone).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('Go')); - await act(async () => gate.resolve()); + await act(() => { + gate.resolve(); + }); expect(screen.getByTestId('state')).toHaveTextContent('done'); expect(onDone).toHaveBeenCalledTimes(1); @@ -256,14 +278,23 @@ describe('useMachine — onDone', () => { function Comp({ onDone }: { onDone: () => void }) { const [, send] = useMachine(machine, { context: { fn: () => gate.promise }, onDone }); - return ; + return ( + + ); } const { rerender } = render(); rerender(); fireEvent.click(screen.getByText('Go')); - await act(async () => gate.resolve()); + await act(() => { + gate.resolve(); + }); expect(freshCallback).toHaveBeenCalledTimes(1); expect(staleCallback).not.toHaveBeenCalled(); @@ -294,7 +325,12 @@ describe('useMachine — live context keeps injected functions current', () => { return (
{snapshot.value} - +
); } @@ -305,7 +341,9 @@ describe('useMachine — live context keeps injected functions current', () => { fireEvent.click(screen.getByText('Go')); expect(screen.getByTestId('state')).toHaveTextContent('running'); - await act(async () => gate.resolve()); + await act(() => { + gate.resolve(); + }); expect(screen.getByTestId('state')).toHaveTextContent('done'); expect(freshFn).toHaveBeenCalledTimes(1); diff --git a/packages/ui/src/mosaic/machine/__tests__/wizard-migration.test.tsx b/packages/ui/src/mosaic/machine/__tests__/wizard-migration.test.tsx index 06286b1081e..2429b62bfa5 100644 --- a/packages/ui/src/mosaic/machine/__tests__/wizard-migration.test.tsx +++ b/packages/ui/src/mosaic/machine/__tests__/wizard-migration.test.tsx @@ -94,9 +94,13 @@ function createWizardMachine(descriptors: StepDescriptor[]): StateMachine { - if (descriptors.length === 0) return ''; + if (descriptors.length === 0) { + return ''; + } let i = 0; - while (i + 1 < descriptors.length && guardHolds(descriptors[i + 1])) i++; + while (i + 1 < descriptors.length && guardHolds(descriptors[i + 1])) { + i++; + } return descriptors[i].id; }; @@ -455,8 +459,18 @@ describe('Wizard AFTER — driven through useMachine', () => { return (
{STEP_LABEL[snapshot.value]} - - + +
); } diff --git a/packages/ui/src/mosaic/machine/assign.ts b/packages/ui/src/mosaic/machine/assign.ts index 9822f37d416..a59754212ba 100644 --- a/packages/ui/src/mosaic/machine/assign.ts +++ b/packages/ui/src/mosaic/machine/assign.ts @@ -1,5 +1,5 @@ -import { ASSIGN } from './types'; import type { AssignAction, EventObject } from './types'; +import { ASSIGN } from './types'; /** * Context-update action creator. The returned object is recognised by the diff --git a/packages/ui/src/mosaic/machine/createActor.ts b/packages/ui/src/mosaic/machine/createActor.ts index 9641cc8b3c8..179ab76bdd0 100644 --- a/packages/ui/src/mosaic/machine/createActor.ts +++ b/packages/ui/src/mosaic/machine/createActor.ts @@ -1,5 +1,4 @@ import { isAssignAction } from './assign'; -import { AFTER, INIT, INVOKE_DONE, INVOKE_ERROR, RECHECK } from './types'; import type { Actions, Actor, @@ -14,11 +13,14 @@ import type { TransitionConfig, Unsubscribe, } from './types'; +import { AFTER, INIT, INVOKE_DONE, INVOKE_ERROR, RECHECK } from './types'; const INIT_EVENT: AnyEventObject = { type: INIT }; function toArray(value: T | T[] | undefined): T[] { - if (value === undefined) return []; + if (value === undefined) { + return []; + } return Array.isArray(value) ? value : [value]; } @@ -129,7 +131,9 @@ export function createActor function startInvoke(event: EventObject): void { const invoke = states[value].invoke; - if (!invoke) return; + if (!invoke) { + return; + } const token = ++invocationToken; // SAFETY: startInvoke is called with the actor's internal EventObject, but // InvokeConfig.src is typed to accept (context, TEvent | DoneInvokeEvent | ErrorInvokeEvent). @@ -143,30 +147,46 @@ export function createActor // before the promise chain begins, so a sync throw escapes the .then handler). new Promise(resolve => resolve(invoke.src(context, event as never))).then( output => { - if (status !== 'active' || token !== invocationToken) return; + if (status !== 'active' || token !== invocationToken) { + return; + } const doneEvent = { type: INVOKE_DONE, output }; const transition = pickTransition(normalizeTransitions(invoke.onDone), doneEvent); - if (!transition) return; - if (takeTransition(transition, doneEvent)) commit(); + if (!transition) { + return; + } + if (takeTransition(transition, doneEvent)) { + commit(); + } }, (error: unknown) => { - if (status !== 'active' || token !== invocationToken) return; + if (status !== 'active' || token !== invocationToken) { + return; + } const errorEvent = { type: INVOKE_ERROR, error }; const transition = pickTransition(normalizeTransitions(invoke.onError), errorEvent); - if (!transition) return; - if (takeTransition(transition, errorEvent)) commit(); + if (!transition) { + return; + } + if (takeTransition(transition, errorEvent)) { + commit(); + } }, ); } function clearAfterTimers(): void { - for (const id of afterTimers) clearTimeout(id); + for (const id of afterTimers) { + clearTimeout(id); + } afterTimers = []; } function startAfterTimers(): void { const afterConfig = states[value].after; - if (!afterConfig) return; + if (!afterConfig) { + return; + } for (const [delayStr, raw] of Object.entries(afterConfig)) { const delay = Number(delayStr); // normalizeTransitions takes `raw: unknown`, so the AfterEvent↔EventObject @@ -174,11 +194,17 @@ export function createActor const transitions = normalizeTransitions(raw); const id = setTimeout(() => { afterTimers = afterTimers.filter(t => t !== id); - if (status !== 'active') return; + if (status !== 'active') { + return; + } const afterEvent: AfterEvent = { type: AFTER, delay }; const transition = pickTransition(transitions, afterEvent); - if (!transition) return; - if (takeTransition(transition, afterEvent)) commit(); + if (!transition) { + return; + } + if (takeTransition(transition, afterEvent)) { + commit(); + } }, delay); afterTimers.push(id); } @@ -187,7 +213,9 @@ export function createActor /** 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 + if (!stateConfig) { + return; + } // degenerate graph (e.g. empty wizard) — nothing to enter runActions(stateConfig.entry, event); if (stateConfig.type === 'final') { @@ -213,7 +241,9 @@ export function createActor const actor: Actor = { start() { - if (started) return actor; + if (started) { + return actor; + } started = true; status = 'active'; // Reset state and context so a restart (e.g. after StrictMode stop/start) @@ -226,7 +256,9 @@ export function createActor }, stop() { - if (status === 'stopped') return; + if (status === 'stopped') { + return; + } started = false; // allow restart (e.g. StrictMode effect cleanup + remount) status = 'stopped'; invocationToken++; // abandon any in-flight invoke @@ -239,10 +271,16 @@ export function createActor }, send(event) { - if (!started || status !== 'active') return; + if (!started || 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 + if (!transition) { + return; + } // event not handled in this state → ignored + if (takeTransition(transition, event)) { + commit(); + } // entry-blocked → no commit, no notify }, getSnapshot() { @@ -262,10 +300,14 @@ export function createActor }, can(event) { - if (!started || status !== 'active') return false; + if (!started || 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); + if (!transition) { + return false; + } + return transition.target === undefined || canEnter(transition.target, event); }, setContext(patch: Partial) { @@ -274,7 +316,9 @@ export function createActor }, recheck() { - if (status !== 'active') return; + if (status !== 'active') { + return; + } const event = { type: RECHECK }; // Self-correct first: if live external data has made the *current* state diff --git a/packages/ui/src/mosaic/machine/types.test-d.ts b/packages/ui/src/mosaic/machine/types.test-d.ts index e79c16ee3dc..9826859ddf6 100644 --- a/packages/ui/src/mosaic/machine/types.test-d.ts +++ b/packages/ui/src/mosaic/machine/types.test-d.ts @@ -210,6 +210,7 @@ describe('createMachine — TStates constrains initial and transition targets', // ─── setup — pre-binds TContext and TEvent ─────────────────────────────────── describe('setup — eliminates repetitive generic params', () => { + // eslint-disable-next-line @typescript-eslint/unbound-method -- setup() returns closures, not this-bound methods const { createMachine: make, assign: a } = setup(); test('createMachine compiles without explicit type parameters', () => { @@ -281,6 +282,7 @@ interface FetchResult { } describe('setup.fromPromise — e.output is typed from src return type', () => { + // eslint-disable-next-line @typescript-eslint/unbound-method -- setup() returns closures, not this-bound methods const { createMachine: make2, assign: a2, fromPromise } = setup(); // Typed async function — fromPromise infers TOutput = FetchResult from its return type. diff --git a/packages/ui/src/mosaic/machine/useMachine.ts b/packages/ui/src/mosaic/machine/useMachine.ts index cc49d551e14..cc91cfe6a99 100644 --- a/packages/ui/src/mosaic/machine/useMachine.ts +++ b/packages/ui/src/mosaic/machine/useMachine.ts @@ -53,7 +53,9 @@ export function useMachine( // 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); + if (options?.context) { + actor.setContext(options.context); + } }); const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); @@ -61,7 +63,9 @@ export function useMachine( const onDoneRef = useRef(options?.onDone); onDoneRef.current = options?.onDone; useEffect(() => { - if (snapshot.status === 'done') onDoneRef.current?.(); + if (snapshot.status === 'done') { + onDoneRef.current?.(); + } }, [snapshot.status]); return [snapshot, actor.send];