Skip to content

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.

—·live·
Legend
  • rounded box: step
  • double border: group
  • stacked box: repeat group
  • dashed box: unresolved group
  • circle: end
  • solid line: next
  • grey line: order
  • dashed line: back
  • red line: dangling
  • filled: active
  • solid outline: visited
  • ring: selected

no flow to draw

Devtools.tsx
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>
);
}

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.

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.

  1. Install the panel. It imports @wizzard-packages/react, an optional peer, so install the React binding beside it.
Terminal window
pnpm add -D @wizzard-packages/devtools@canary
  1. Call devtools() once, and pass what it returns to createWizard in plugins.
  2. Render WizardDevtools with that same object as plugin, inside the provider that carries the wizard - Devtools.tsx builds the wizard itself and hands it to WizardProvider as wizard. Outside a provider, pass the wizard as the panel’s wizard prop instead; with neither, the panel has nothing to watch and says so as devtools-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”.

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.

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.

devtools-headless/run.ts
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();
Output
frames: 5, outcomes: 3
next (start): reached details
next (call): invalid
next (call): reached review

The 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.

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.

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.

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.

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.