The source it is running
These blocks are the files the application above imports, read at build time. There is no copy of them to fall out of date.
flow.ts
Three steps, a storage key and an application version. The version is the application’s, not the flow’s: it is bumped when the meaning of what was collected changes.
import { defineFlow, step } from '@wizzard-packages/core';
/**
* R-B: an application that survives a reload, and an asynchronous check that a
* reload in the middle of must not corrupt.
*
* Three steps, because what this one has to demonstrate is not a route: it is
* what is stored, what is refused, and what a person is told when the form they
* half filled in comes back empty.
*/
export const reload = defineFlow({
id: 'reload',
version: 1,
order: ['account', 'workspace', 'confirm'],
steps: {
account: step<{ email: string }>({
label: 'Account',
validate: { $ref: 'account' },
}),
workspace: step<{ name: string }>({
label: 'Workspace',
// The check is a lookup, so it is asynchronous, so `next()` is a promise
// and the wizard is `busy` until it answers. That is what the buttons
// above read, and what a reload has to be safe in the middle of.
validate: { $ref: 'workspace' },
}),
confirm: step({ label: 'Confirm' }),
},
policy: 'free',
});
/** Where the session is kept. One key per flow. */
export const STORAGE_KEY = 'wizzard:example:reload';
/**
* The application's own version, not the flow's. It is bumped when the meaning
* of what was collected changes, and a session written under a different one is
* refused rather than half-understood.
*/
export const APP_VERSION = 1;
registry.ts
One plain validator and one that has to ask somebody. A resolver is handed no abort signal, so cancelling does not stop the lookup - it makes the answer irrelevant.
import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/**
* The validators. One is a plain function of the data; the other has to ask
* somebody, so it is a promise.
*
* A resolver is not handed an abort signal, so `cancel()` does not stop the
* lookup that is already in the air - it makes the answer irrelevant. The
* navigation epoch moved on, so whatever comes back is discarded rather than
* written over a wizard that has since gone somewhere else. That is the
* property a reload in the middle of a check depends on.
*/
type Fields = Record<string, string> | null;
const slice = (scope: Scope, id: string): Record<string, unknown> =>
(scope.data[id] as Record<string, unknown> | undefined) ?? {};
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** The names the imaginary service already has. */
export const TAKEN = ['acme', 'demo', 'test'];
/** How long the lookup pretends to take. */
export const LOOKUP_MS = 700;
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
export const registry: AsyncRegistry = {
account: (_args, scope): Fields => {
const { email } = slice(scope, 'account');
if (typeof email !== 'string' || email === '') return { email: 'Enter your email address.' };
if (!EMAIL.test(email)) return { email: 'That does not look like an email address.' };
return null;
},
workspace: async (_args, scope): Promise<Fields> => {
const { name } = slice(scope, 'workspace');
const wanted = typeof name === 'string' ? name.trim() : '';
// The shape is checked before anything is asked: there is no point paying
// for a round trip to be told what a regular expression already knew.
if (wanted === '') return { name: 'Choose a name for your workspace.' };
if (!/^[a-z0-9-]{3,}$/.test(wanted)) {
return { name: 'Lower case letters, digits and dashes, three or more.' };
}
await wait(LOOKUP_MS);
if (TAKEN.includes(wanted)) return { name: `"${wanted}" is taken. Try another one.` };
return null;
},
};
outcome.ts
The plugin answers with a reason; only the host knows what the form is called, so it is the host that turns the reason into a sentence.
import type { RestoreOutcome } from '@wizzard-packages/plugins/persist';
import { STORAGE_KEY } from './flow';
/**
* What a person is told about the session they did or did not get back.
*
* A form that was half filled in and is now empty looks like a bug to whoever
* filled it, and silence is why they would think so. The plugin answers with a
* reason for exactly this: the host turns it into a sentence, because only the
* host knows what the form is called and who is reading it.
*
* Shared by both renderings so the two cannot drift into saying different
* things about the same event.
*/
export function describeRestore(outcome: RestoreOutcome | null): string {
if (outcome === null) return 'Starting.';
if (outcome.restored) return 'Restored. You are back where you left off.';
switch (outcome.reason) {
case 'persist/nothing-stored':
return 'Nothing saved yet. This one will be, from the first answer.';
case 'persist/unavailable':
return 'Saving unavailable: this browser did not allow storage, which private windows do.';
case 'snapshot/other-flow':
return 'Reset: the saved session was written by an older version of this application.';
case 'snapshot/version':
return 'Reset: the saved session is in a format this build does not read.';
case 'snapshot/unknown-step':
return 'Reset: the saved session was standing on a step this flow no longer has.';
default:
return `Reset: the saved session could not be read (${outcome.reason}).`;
}
}
/**
* Ages the stored session, then reloads.
*
* A version bump is the one refusal a visitor cannot produce by hand, and it is
* the one worth seeing: it is what happens to everybody with a session open on
* the day an application ships a change to what it collects.
*/
export function simulateUpgrade(): void {
// Every one of these can throw in a browser that refuses storage - the
// property access included, which is why this is a try and not a check. The
// page already says saving is unavailable there; the button does nothing
// rather than taking the example down with it.
try {
const raw = globalThis.localStorage.getItem(STORAGE_KEY);
if (raw === null) return;
const stored = JSON.parse(raw) as Record<string, unknown>;
globalThis.localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...stored, appVersion: 0 }));
} catch {
return;
}
globalThis.location.reload();
}
App.tsx
The rendering. The plugin is installed in the browser only, and what it restored appears one tick after mount - a restored session differs from the empty step the server rendered.
import {
WizardProvider,
useErrors,
useField,
useNavigation,
useStep,
useWizard,
useWizardSelector,
} from '@wizzard-packages/react';
import { persist, type RestoreOutcome } from '@wizzard-packages/plugins/persist';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { APP_VERSION, STORAGE_KEY, reload } from './flow';
import { describeRestore, simulateUpgrade } from './outcome';
import { TAKEN, registry } from './registry';
/**
* R-B on the React binding: a form that survives a reload, and a check that a
* reload in the middle of must not corrupt.
*
* Two things here are not obvious and are the reason the file is worth reading.
*
* The plugin is installed in the browser only, and what it restored is not
* shown until after mount. A restored session is by definition different from
* the empty first step the server rendered, so a client that painted it
* immediately would be correcting the server's markup - the mismatch React
* warns about. The first client paint is therefore the server's frame, and the
* session appears one tick later, which is also when the outcome can be told.
*
* `busy` is read off the engine, never tracked here. It is true from the moment
* `next()` starts until the validator answers, which is what disables the
* button and what "Checking" is spelled from.
*/
export default function ReloadApp(): ReactNode {
// Whatever the plugin decided, kept out of React's state until it is safe to
// read: `init` runs while the engine is being created, which is during a
// render, and setting state there is not allowed.
const restored = useRef<RestoreOutcome | null>(null);
const plugins = useMemo(
() =>
typeof window === 'undefined'
? []
: [
persist({
key: STORAGE_KEY,
version: APP_VERSION,
onRestore: (outcome) => {
restored.current = outcome;
},
}),
],
[]
);
return (
<WizardProvider flow={reload} registry={registry} plugins={plugins}>
<Reload restored={restored} />
</WizardProvider>
);
}
const LABELS: Record<string, string> = {
account: 'Your account',
workspace: 'Name your workspace',
confirm: 'Confirm',
};
function Reload(props: { restored: { current: RestoreOutcome | null } }): ReactNode {
const wizard = useWizard();
const { current, active, isLast } = useStep();
const { back, canBack, isBusy } = useNavigation();
const errors = useErrors();
const [email, setEmail] = useField<string>('account.email');
const [name, setName] = useField<string>('workspace.name');
const status = useWizardSelector((s) => s.status);
// Whether the run is finished is read off the engine, not remembered here: a
// flag in this component would come back `false` after a reload and send the
// visitor round the last step again. The snapshot marks a finished wizard, so
// `status` survives the reload this application exists for.
const ended = status === 'done';
const [mounted, setMounted] = useState(false);
const [outcome, setOutcome] = useState<RestoreOutcome | null>(null);
const [announcement, setAnnouncement] = useState('');
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => {
setMounted(true);
setOutcome(props.restored.current);
}, [props.restored]);
const moved = useRef(false);
useEffect(() => {
if (current === null || !mounted) return;
if (!moved.current) {
moved.current = true;
return;
}
heading.current?.focus();
const { active: route } = wizard.getSnapshot();
const at = route.indexOf(current) + 1;
setAnnouncement(`${LABELS[current] ?? current}. Step ${at} of ${route.length}.`);
}, [current, mounted, wizard]);
// Until the first effect has run, this is the frame the server sent: the step
// the flow is about to enter, with the controls saying they are not ready.
const standing = mounted ? (current ?? active[0] ?? 'account') : 'account';
const starting = !mounted || current === null;
async function onNext(): Promise<void> {
if (ended) {
setAnnouncement('');
wizard.reset();
await wizard.start();
return;
}
const result = await wizard.next();
if (result.ok) {
if (result.to === '@end') setAnnouncement('Finished.');
return;
}
if (result.reason === 'superseded' || result.reason === 'aborted') return;
const fields = Object.values(result.errors ?? {});
setAnnouncement(
fields.length === 0 ? `That move was refused: ${result.reason}.` : fields.join(' ')
);
}
/**
* Back while a check is in flight. `cancel()` aborts the navigation, so the
* answer that arrives later belongs to an epoch that has passed and is
* discarded; the move backwards is a new one and wins.
*/
async function onBack(): Promise<void> {
wizard.cancel();
await back();
}
return (
<div className="app">
<p className="app-restore" role="status" aria-live="polite">
{describeRestore(mounted ? outcome : null)}
</p>
{mounted && ended ? (
<>
<h2 ref={heading} tabIndex={-1}>
Done
</h2>
<p>
The session is still saved: reload and you come back to this. Start again replaces it
with an empty one, because that is a commit like any other.
</p>
<div className="actions">
<button
className="button button-accent"
type="button"
onClick={() => {
void onNext();
}}
>
Start again
</button>
</div>
</>
) : (
<form
onSubmit={(event) => {
event.preventDefault();
void onNext();
}}
>
<h2 ref={heading} tabIndex={-1}>
{LABELS[standing] ?? standing}
</h2>
{standing === 'account' && (
<Field
id="reload-email"
label="Email"
type="email"
autoComplete="email"
value={mounted ? email : ''}
onChange={setEmail}
error={errors['email']}
/>
)}
{standing === 'workspace' && (
<Field
id="reload-name"
label="Workspace name"
hint={`Checked against the service when you continue. ${TAKEN.join(', ')} are taken.`}
value={mounted ? name : ''}
onChange={setName}
error={errors['name']}
/>
)}
{standing === 'confirm' && (
<p>
Reload the page now and you come back to this step, with both answers still in it.
What was stored is the durable snapshot, so nothing that described a moment - a
navigation in flight, a validator's errors - came back with it.
</p>
)}
<div className="actions">
<button className="button button-accent" type="submit" disabled={starting || isBusy}>
{isBusy ? 'Checking…' : isLast ? 'Finish' : 'Next'}
</button>
<button
className="button button-secondary"
type="button"
disabled={starting || !canBack}
onClick={() => {
void onBack();
}}
>
Back
</button>
</div>
</form>
)}
<dl className="app-state">
<dt>status</dt>
<dd>{starting ? 'init' : status}</dd>
<dt>saved</dt>
<dd>{STORAGE_KEY}</dd>
</dl>
<p className="app-note">
<button
className="button button-secondary"
type="button"
disabled={!mounted}
onClick={simulateUpgrade}
>
Ship version 2 and reload
</button>
<span>
Ages the saved session by one version and reloads, which is what everyone with a form open
meets on the day an application changes what it collects.
</span>
</p>
<p className="app-live" role="status" aria-live="polite">
{announcement}
</p>
</div>
);
}
function Field(props: {
id: string;
label: string;
value: string | undefined;
onChange: (value: string) => void;
type?: string;
autoComplete?: string;
hint?: string;
error?: string | undefined;
}): ReactNode {
const described = [
props.hint === undefined ? null : `${props.id}-hint`,
props.error === undefined ? null : `${props.id}-error`,
].filter((id): id is string => id !== null);
return (
<div className="field">
<label className="field-label" htmlFor={props.id}>
{props.label}
</label>
<input
id={props.id}
type={props.type ?? 'text'}
autoComplete={props.autoComplete}
value={props.value ?? ''}
aria-invalid={props.error === undefined ? undefined : true}
aria-describedby={described.length === 0 ? undefined : described.join(' ')}
onChange={(e) => {
props.onChange(e.target.value);
}}
/>
{props.hint !== undefined && (
<span className="field-hint" id={`${props.id}-hint`}>
{props.hint}
</span>
)}
{props.error !== undefined && (
<span className="field-error" id={`${props.id}-error`}>
{props.error}
</span>
)}
</div>
);
}