Devtools
A wizard that will not move is the hard bug, because nothing happened: no exception, no log line, a button that does nothing. The panel exists for that one question - why is the wizard where it is - and answers it with three views over one snapshot.
no flow to draw
import { createWizard } from '@wizzard-packages/core';import { WizardProvider } from '@wizzard-packages/react';import { WizardDevtools, devtools } from '@wizzard-packages/devtools';
import { signup } from './flow';import { Wizard } from './App';
/** * The panel, wired the way the documentation's three steps describe it. * * Two things are easy to get wrong and both are here: the same `devtools()` * object goes to `createWizard` and to the panel - two instances leave the * refusal rows empty - and the container has a height, because the panel is * docked and fills what it is given rather than floating over the page. */const dt = devtools();const wizard = createWizard({ flow: signup, plugins: [dt] });
export function App(): React.ReactNode { return ( <WizardProvider wizard={wizard}> <Wizard /> <div style={{ height: 360 }}> <WizardDevtools plugin={dt} /> </div> </WizardProvider> );}Three views, one snapshot
Section titled “Three views, one snapshot”Graph draws the flow the wizard is standing in: which steps are reachable now, which branch it took, and where the current step sits among them. Selecting a node inspects it. It never navigates, so reading the picture cannot move the wizard someone is debugging.
State is what was committed, with a diff per commit. It is the answer to “did that field
actually reach the wizard”, which is a different question from “did I call set”.
Activity is the one that pays for the panel. It lists what the wizard did in order - commits
it made, moves it refused, and the one in flight. A refused move leaves no record in state: one
a guard or a plugin refuses commits nothing, and one a validator refuses commits only its errors.
Without this view a refused next() is invisible as a move, which is exactly the moment someone
opens devtools. The rows come from the plugin, and the header says so when the
plugin is missing.
The strip above the tabs answers without a tab change: where the wizard is, whether the panel is
live or pinned, and the latest outcome. A refused next() reads
✗ next invalid · email: required - the move, why it was refused, and the fields that refused
it - and clicking it opens Activity. Selecting a row there pins the panel to the state that row
happened in, so the graph, the state and the inspector all answer as of that moment. The inspector
prints each step’s when and whether it held, or Not evaluated where the panel cannot know:
inside a repeat group, which has no loop scope to evaluate against, and for a $ref, which needs
the registry the panel does not have.
Mounting it
Section titled “Mounting it”Three steps, and two ways to get it wrong. Devtools.tsx at the top of this page is all three;
it renders the quickstart’s Wizard component from App.tsx and its signup flow from
flow.ts, both in examples/quickstart/src.
- Install the panel. It imports
@wizzard-packages/react, an optional peer, so install the React binding beside it.
pnpm add -D @wizzard-packages/devtools@canary- Call
devtools()once, and pass what it returns tocreateWizardinplugins. - Render
WizardDevtoolswith that same object asplugin, inside the provider that carries the wizard -Devtools.tsxbuilds the wizard itself and hands it toWizardProvideraswizard. Outside a provider, pass the wizard as the panel’swizardprop instead; with neither, the panel has nothing to watch and says so asdevtools-no-wizard.
The same object goes to both. devtools() returns a plugin; the engine writes to it and the
panel reads from it. Two calls produce two objects, the panel reads the one nothing writes to,
and the Activity view sits empty while everything else looks correct.
The container needs a height. The panel is docked rather than floating: it fills what it is given. Dropped into a page with no height it collapses to nothing, which reads as “the panel is broken” rather than “the panel is zero pixels tall”.
The props
Section titled “The props”| Prop | What it is |
|---|---|
wizard |
The wizard to watch. Defaults to the one from WizardProvider. |
plugin |
The object from devtools(). Without it there are no refusal rows. |
subFlows |
Definitions a group step names, so nested flows can be drawn. Compared by its entries, so a literal written in a render is fine. |
layout |
Your own positions. There is a built-in layout; this replaces it. |
redact |
Runs once, at export, over a copy of the whole bundle. |
onRecord |
Receives a recorded session bundle, after redact, once per bundle the preview builds. An inline function is fine. |
defaultTab |
'graph', 'state' or 'activity'. Defaults to the graph. |
limits |
Ceilings on activity rows, frames and diff rows, for a long-running session. |
It is a React panel
Section titled “It is a React panel”WizardDevtools is a React component. It needs @wizzard-packages/core 1.x, and for the panel
@wizzard-packages/react 1.x with React 18 or newer, all as peer dependencies; the React ones
are optional, because the headless entry below uses none of them. A Vue application does not
mount the panel.
What a Vue host uses instead is @wizzard-packages/devtools/headless, which is the same
machinery without React or the DOM: devtools() installs into any wizard, and recordSession
produces the same bundle the panel’s record button writes. The recording can then be read
anywhere - a test, a bug report, the inspector on this site.
The script below records the two-step trip from Hooks - its flow.ts and
registry.ts - through a refused move and an accepted one, and prints what the bundle holds. It
is examples/tasks/src/devtools-headless/run.ts in the repository, and a test compares what it
prints with the output shown.
import { createWizard } from '@wizzard-packages/core';import { devtools, recordSession } from '@wizzard-packages/devtools/headless';
import { trip } from '../hooks/flow';import { registry } from '../hooks/registry';
// One `devtools()` object, given to the engine as a plugin and to the// recording: the engine writes the attempts to it, and the recording reads// them back. Two calls would make two objects, and the recording would read// the one nothing writes to.const dt = devtools();const wizard = createWizard({ flow: trip, registry, plugins: [dt] });const recording = recordSession(wizard, { plugin: dt });
await wizard.start();// Refused by the validator. Its errors are committed, so they are a frame, but// which move was refused, and why, is only in what the plugin keeps.await wizard.next();wizard.set('details', { name: 'Lisbon' });await wizard.next();
recording.stop();const bundle = recording.bundle();// In an application: `writeFileSync('bundle.json', JSON.stringify(bundle))`,// or attach it to a bug report. The site's inspector opens the same file.console.log(`frames: ${bundle.meta.frames}, outcomes: ${bundle.meta.outcomes}`);for (const outcome of bundle.outcomes) { const { result } = outcome; console.log( `${outcome.intent.type} (${outcome.source}): ` + (result === undefined ? 'threw' : result.ok ? `reached ${String(result.to)}` : result.reason) );}
// The application created the wizard, so it destroys it. That drops every// listener, a recording's included: one still running records nothing after.wizard.destroy();frames: 5, outcomes: 3next (start): reached detailsnext (call): invalidnext (call): reached reviewThe refused next() is an outcome with its reason. It is also a frame, because a validator’s
refusal commits the errors it produced, but that frame says nothing about the move. Without
plugin in recordSession the frames are the same and the outcomes are empty.
A bundle replays structure and data: the flow definition, every settled state, and the outcomes. It does not replay resolver behaviour - a named resolver is not in the bundle, so a flow that fetches its options shows the options it recorded.
What the headless entry promises, and what it does not
Section titled “What the headless entry promises, and what it does not”devtools(), recordSession, the bundle types and the panel itself are public API under
semver - a recording attached to a bug report stays readable across minor releases, which is the
point of it.
Three helpers the panel and this site’s pages are drawn with are not: layoutGraph, which gives
a graph coordinates, formatExpr, which shortens an expression to a label, and diffState,
which lists what changed between two states, along with the types they use - PositionedGraph,
Positioned and Change. They are exported because the docs needed them, and they may change
in a minor release. Use them for a tool you can rebuild, not for something a user depends on.
Redaction
Section titled “Redaction”redact runs once, at export, on a copy of the whole bundle. dt here and below is the object
from devtools(), as in Devtools.tsx above:
<WizardDevtools plugin={dt} redact={(bundle) => ({ ...bundle, session: { ...bundle.session, frames: bundle.session.frames.map((frame) => ({ ...frame, data: { ...frame.data, card: '[redacted]' }, })), }, })}/>Frames are unredacted in memory until export: the hook shapes what leaves, not what is kept. The export preview shows the counts, the size and whether the hook ran before anything is copied, and a hook that throws stops the export and copies nothing.
What it never does
Section titled “What it never does”It never navigates, never writes to the wizard, and never throws into the host. A tool that crashes the application it is diagnosing is worse than no tool, and one that moves the wizard while you read it destroys the state you opened it to see.
Keeping it out of production
Section titled “Keeping it out of production”A dev dependency is about who installs the package, not about what a bundler does with it. A static import that is rendered unconditionally is in the bundle, and the same install without dev dependencies fails to build because the import has nothing to resolve. Gate the render yourself:
{ process.env.NODE_ENV !== 'production' && ( <div style={{ height: 360 }}> <WizardDevtools plugin={dt} /> </div> );}// Vite reads the same thing from its own flag.{ import.meta.env.DEV && <WizardDevtools plugin={dt} />;}The engine itself carries no reference to the panel - the plugin is something you pass in - so what a gate has to cover is the panel and its import, not the wizard.
The panel is a client component. In the Next.js App Router, render it from a file that starts
with 'use client';
examples/next-app in
the repository is a runnable setup.
Errors
Section titled “Errors”Every message ends in the URL of the page that explains it, one page per code:
devtools-no-wizard,
devtools-no-plugin,
devtools-render-failed,
devtools-stopped,
devtools-export-failed and
devtools-bundle-unsupported. A graph that cannot be
drawn shows devtools-render-failed in the Graph tab and writes the same line to the console,
followed by the thrown error and the React component stack that names the component that threw.