Skip to content

refactor(ui): drive ConfigureSSO wizard navigation with a state machine#8715

Draft
iagodahlem wants to merge 19 commits into
mainfrom
iago/orgs-1599-configure-sso-state-machine
Draft

refactor(ui): drive ConfigureSSO wizard navigation with a state machine#8715
iagodahlem wants to merge 19 commits into
mainfrom
iago/orgs-1599-configure-sso-state-machine

Conversation

@iagodahlem
Copy link
Copy Markdown
Member

Description

<ConfigureSSO />'s wizard had accreted four structural problems that make it hard to extend and reason about — and would calcify as more providers (Google, Entra, OIDC) and DNS/TXT domain verification land:

  • Steps decided their own navigation. Routing logic lived inside step bodies (e.g. Select Provider branched on email-verification state to pick the next step), so the flow was spread across the steps rather than described in one place.
  • Data was fetched and derived in several places. useUser/useSession/test-run reads were scattered across steps; isDomainTakenByOtherOrg was computed twice; test runs were read three times.
  • Mutations were drilled through props with inconsistent reverification. useReverification was hand-wrapped in some files and missing on others (connection create, the SAML configure submit, test-run create).
  • Footer/submit lifecycle was copy-pasted per step. Every step re-implemented setError → setLoading → await → advance → catch → setIdle, patched over with a step-change error-reset effect.

This refactors the wizard onto a small state machine, with no changes to the public API.

What changed

  • Navigation is a pure reducer over a static step graph. NEXT/BACK/GOTO/RESET are pure functions of (state, event, facts); the breadcrumb and footer read from it. Steps no longer route — they emit submit intent and the machine decides where to go. Rendering is match-based (no first-frame flashes).
  • One data layer. A single hook fetches user/session/connection/test-runs/organization once, above the provider, and derives a single facts object consumed everywhere. The provider never sees a loading state — the skeleton is gated one level up.
  • One mutations layer. Every connection mutation is centralized and wrapped with reverification at the source, closing the three previous gaps. This also makes the upcoming org-scoped enterprise-connections endpoint migration a near one-file change.
  • One submit runner + composed per-step footers. A shared useSubmitRunner owns the submit lifecycle; each step composes its own footer (Submit/Previous/Reset) with local state — no footer-action registry. The two SAML configure twins collapse into one shared step.
  • Flow order now leads with domain verification. When the admin's email isn't already verified, domain verification comes before provider selection (it's skipped when the email is already verified). The DNS/TXT domain-verification rebuild itself is a follow-up; this PR establishes the order and the machine it slots into.
  • Removed: the legacy initial-step deriver, the step-change error-reset effect, the duplicate domain-taken computation, and an imperative test-run re-check.

Test plan

  • Unit: pure reducer/guards/derived-facts tests (no React mount) + component tests — full suite green.
  • pnpm type-check clean; no new lint findings.
  • Manual browser QA (sandbox): full Verify → Select Provider → Configure → Test → Confirm flow against a live Keycloak SAML IdP (successful test run + results pagination); refresh-at-each-step lands on the correct step (active connection → Confirmation); breadcrumb jumps; reset rewinds to the start and deletes the connection.

Notes

  • No public API changes; @clerk/ui patch changeset included.
  • The nested domain-verification and provider-configuration sub-flows are intentionally left as-is for now and slotted under the machine as black boxes; their internal rebuild (DNS/TXT, multi-domain) is a separate follow-up.

@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jun 2, 2026 8:25pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 1, 2026

🦋 Changeset detected

Latest commit: d88d8ea

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@clerk/ui Patch
@clerk/chrome-extension Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 1, 2026

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8715

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8715

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8715

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8715

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8715

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8715

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8715

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8715

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8715

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8715

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8715

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8715

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8715

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8715

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8715

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8715

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8715

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8715

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8715

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8715

commit: 5524ad3

iagodahlem added 10 commits June 2, 2026 10:05
… part

Introduce the React primitives the ConfigureSSO state-machine cutover builds on,
without changing the live wizard yet:

- useMachine(facts): owns the reducer state and feeds the pure reducer the
  current facts at dispatch time via a render-updated ref (no effect-based
  facts sync), keeping dispatch identity stable across server-state refetches.
- useSubmitRunner: centralizes the submit lifecycle (clear error -> loading ->
  advance/jump via NEXT/GOTO or surface error -> idle), building the step ctx
  from context + the machine dispatch.
- Step.Footer.Submit: composed Continue button wired to the runner; owns its
  in-flight state from the card.
- WizardMachineProvider/useWizardMachine: a single sibling context that exposes
  the machine to steps, footer, and header.
- Wizard gains an onComplete terminal hook so a nested flow can bubble its last
  step into a host state machine instead of a parent wizard.

Thread primaryEmailAddress through the data hook + context so the runner can
derive the connection name without each step calling useUser.
Cut the live wizard over from the imperative <Wizard> engine to the pure state
machine. Top-level navigation is now reduce/initialState from the reducer, the
ordered STEPS from transitions, and the bodies from STEP_BODIES; steps no longer
own routing.

- ConfigureSSO.tsx mounts useMachine(facts) and renders STEP_BODIES[current]
  inside the existing ProfileCard/NavBar chrome, via WizardMachineProvider. The
  step-change error effect is gone (the runner clears the card error per submit).
- The header reads the machine + transitions: visible steps are the enabled
  steps minus select-provider, completion stays positional (behavior-equivalent
  to the old breadcrumb), current is machine.current, clicks dispatch GOTO.
- Simple steps (select-provider, test, confirmation) use Step.Footer.Submit +
  the runner; Previous dispatches BACK; confirmation's reconfigure dispatches
  GOTO configure and reset dispatches RESET.
- submitSelectProvider returns { ok: true, goTo: 'configure' }: a successful
  create flips hasConnection, disabling select-provider, so a plain NEXT would
  no-op; GOTO is required.
- Nested-delegating steps (verify-domain, configure) keep their inner <Wizard>
  untouched; only their terminal step advances the machine via an injected
  onComplete.

Re-point the select-provider tests at the machine dispatch and the submit test
at the new goTo result.
deriveInitialStep (and its test) are dead now that the machine's initialState is
the sole authority for where the wizard mounts on (re)load; initialStepId no
longer lives on the context. The ResetCardErrorOnStepChange effect sentinel is
also gone — the submit runner clears the card error per submit.
@iagodahlem iagodahlem force-pushed the iago/orgs-1599-configure-sso-state-machine branch from 6e9a085 to 5524ad3 Compare June 2, 2026 13:38
…ed primitive

Replace the effect-timed step registration with a domain-agnostic state
machine whose step graph is derived synchronously from <Wizard.Step> children
during render, so the active step is always resolved against a known graph (no
inconsistency window).

- useWizardMachine(config) takes derived step descriptors + a named-guards
  record ({ [name]: () => boolean }) and returns the nav surface; guards are
  evaluated in the hook layer before dispatch. No domain types leak in.
- The pure reducer (NEXT/PREV/GOTO/RESET) reads the descriptors + guards as an
  argument, kept fresh via a render-updated ref so dispatch identity stays
  stable.
- useWizard() is now the only consumer surface: goNext / goPrev / goToStep /
  reset + the derived current / activeSteps / isFirstStep / isLastStep. The
  machine is an internal detail of the primitive.
- <Wizard.Step> carries id / label / guard / enabled / terminal / hidden as
  props; children-derivation descends through fragments and intermediate
  elements, stopping at nested-wizard and component boundaries.
- Remove the old parallel machine (per-component reducer + machine context +
  the layout-effect step registry + the stepBodies/transitions tables).
Wire ConfigureSSO onto the generic Wizard primitive: a new ConfigureSSOSteps
component declares the five top-level steps (verify-domain, select-provider
hidden, configure, test, confirmation) as <Wizard.Step> children with a named
guards record sourced from the existing derived facts. The step graph is now
the JSX, replacing the stepBodies map + transitions table.

- ConfigureSSO renders <ConfigureSSOSteps/> inside the existing providers;
  the loading gate stays one level up.
- Steps, the breadcrumb, the submit runner, and the reset dialog consume
  useWizard() only (goNext / goPrev / goToStep / reset) — the state machine is
  hidden behind the primitive.
- Each SAML provider sub-flow now hosts its own nested <Wizard> with statically
  declared steps so the graph is derivable at that boundary; the configure body
  mounts the provider component and passes onComplete through to bubble the
  inner terminal step into the top-level wizard.
Add a pure, tech-agnostic domain module (no React, no hooks) holding the
connection-level predicates the ConfigureSSO wizard derives state from:
isActive, hasMinimumConfiguration, getProvider, isOwnedByOtherOrganization.
Connection facts live here; user/session and test-run facts are composed in
the data hooks instead. Unit-tested in isolation.
…tionMutations

Move the mutations hook into hooks/ and rename it (and its EnterpriseConnectionMutations
type) to drop the wizard-specific naming. The surface is connection-domain only —
no wizard/step/navigation concepts — so it can be reused for custom self-serve SSO
flows. Every mutation stays useReverification-wrapped. Update all importers.
Move the test-run probe into hooks/ and drop "controller" from the name. The
hook becomes the single source of test-run state and now exposes both an
initial isLoading (no data yet → full skeleton) and an isFetching signal for
background refetches that keep previously-loaded data visible. The Test step's
own paginated list is unchanged here; folding it onto this source is a follow-up.
Compose the enterprise-connection flow into one SDK-grade hook: the source
query (the single swappable seam for a future endpoints migration), the pure
domain predicates, the reverification-wrapped mutations, and the test-run
state. It returns the connection data, named derived state, mutations, and a
test-run refresh handle — never an opaque facts bag.

Delete deriveFacts and the old data/ hook; fold their logic into the domain
module + this hook. Retire machine/guards.ts: the wizard guards are now trivial
boolean reads sourced from the hook's derived state via context. The context
keeps its shape, now fed by this hook. The deriveFacts unit coverage moves into
the domain module's tests (including the verified-gated domain-taken predicate).
The Test step ran its own paginated test-run fetch while the umbrella hook
also fetched a success probe, so test-runs were fetched in two places. Fold
both onto useEnterpriseConnectionTestRuns as the single source: it now owns
the success probe, the paginated list the table renders, and the page cursor.
The Test step reads rows, totalCount, polling, page, and the table-level
loading flag from context and no longer fetches anything itself.

Loading is split into two signals: the cold isLoading drives the full
skeleton on initial load (the global fetch covers the test-runs), and the
list isFetching drives only the table spinner on re-entry while keeping
previous rows visible. To honor "initial-load landing on the test step does
not refetch, but navigating in later does", the generic Wizard now exposes
isInitialStep, backed by a latched hasNavigated flag in the reducer (more
robust than history length, which a back-step to the mount step would reset).
The step fires a one-shot, mount-only refresh when isInitialStep is false.
A nested wizard's terminal goNext now bubbles to the parent wizard's
goNext automatically — the nested wizard already holds the parent in
context, so the explicit onComplete callback was redundant. Remove
onComplete from the Wizard primitive and its callsites (the configure
sub-flow, the verify-domain inner flow, and the four SAML provider
sub-flows). A top-level wizard with no parent treats a terminal goNext
as a no-op.
Delete the central submit runner and the pure submit use-cases that only
existed to feed it. The footer Continue is a plain button again; each
step owns a local handleContinue (clear error → set loading → await the
context mutation → advance → catch via handleError → settle to idle) and
passes it to the footer.

SelectProviderStep creates the connection and jumps to configure
(goToStep, since the create disables select-provider). TestConfiguration
gates Continue on a successful test run, surfacing the inline validation
message when none exists. The four SAML provider sub-flows already used
this shape. Navigation stays on the useWizard facade; the state machine
is never touched directly by a step.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant