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
The same file the React page shows. A session saved on one page restores on the other: the binding is a rendering, and the snapshot does not belong to it.
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
Also the same file. 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
And this one, so the two renderings cannot drift into saying different things about the same event.
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();
}
Reload.vue
The rendering, with `onMounted` where the React file has an effect. The restored session appears one tick after mount for the same reason.
<script setup lang="ts">
import type { RestoreOutcome } from '@wizzard-packages/plugins/persist';
import {
useErrors,
useField,
useNavigation,
useStep,
useWizard,
useWizardSelector,
} from '@wizzard-packages/vue';
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import { STORAGE_KEY } from './flow';
import { describeRestore, simulateUpgrade } from './outcome';
import { TAKEN } from './registry';
/**
* R-B on the Vue binding. The same application as `App.tsx`, message for
* message, with `onMounted` where the other has an effect.
*
* `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.
*/
const props = defineProps<{ restored: { outcome: RestoreOutcome | null } }>();
const LABELS: Record<string, string> = {
account: 'Your account',
workspace: 'Name your workspace',
confirm: 'Confirm',
};
const wizard = useWizard();
const { current, active, isLast } = useStep();
const { back, canBack, isBusy } = useNavigation();
const errors = useErrors();
const email = useField<string>('account.email');
const name = 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 = computed(() => status.value === 'done');
const mounted = ref(false);
const outcome = ref<RestoreOutcome | null>(null);
const announcement = ref('');
const heading = useTemplateRef<HTMLHeadingElement>('heading');
onMounted(() => {
mounted.value = true;
outcome.value = props.restored.outcome;
});
// Until mount, 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 = computed(() =>
mounted.value ? (current.value ?? active.value[0] ?? 'account') : 'account'
);
const starting = computed(() => !mounted.value || current.value === null);
const restoreLine = computed(() => describeRestore(mounted.value ? outcome.value : null));
const hint = computed(
() => `Checked against the service when you continue. ${TAKEN.join(', ')} are taken.`
);
let moved = false;
watch(current, (to) => {
if (to === null || !mounted.value) return;
if (!moved) {
moved = true;
return;
}
heading.value?.focus();
const at = active.value.indexOf(to) + 1;
announcement.value = `${LABELS[to] ?? to}. Step ${at} of ${active.value.length}.`;
});
async function onNext(): Promise<void> {
if (ended.value) {
announcement.value = '';
wizard.reset();
await wizard.start();
return;
}
const result = await wizard.next();
if (result.ok) {
if (result.to === '@end') announcement.value = 'Finished.';
return;
}
if (result.reason === 'superseded' || result.reason === 'aborted') return;
const fields = Object.values(result.errors ?? {});
announcement.value =
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();
}
</script>
<template>
<div class="app">
<p class="app-restore" role="status" aria-live="polite">{{ restoreLine }}</p>
<template v-if="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 class="actions">
<button class="button button-accent" type="button" @click="onNext()">Start again</button>
</div>
</template>
<form v-else @submit.prevent="onNext()">
<h2 ref="heading" tabindex="-1">{{ LABELS[standing] ?? standing }}</h2>
<div v-if="standing === 'account'" class="field">
<label class="field-label" for="reload-email">Email</label>
<input
id="reload-email"
:value="mounted ? (email ?? '') : ''"
type="email"
autocomplete="email"
:aria-invalid="errors['email'] ? true : undefined"
:aria-describedby="errors['email'] ? 'reload-email-error' : undefined"
@input="email = ($event.target as HTMLInputElement).value"
/>
<span v-if="errors['email']" id="reload-email-error" class="field-error">
{{ errors['email'] }}
</span>
</div>
<div v-else-if="standing === 'workspace'" class="field">
<label class="field-label" for="reload-name">Workspace name</label>
<input
id="reload-name"
:value="mounted ? (name ?? '') : ''"
:aria-invalid="errors['name'] ? true : undefined"
:aria-describedby="
errors['name'] ? 'reload-name-hint reload-name-error' : 'reload-name-hint'
"
@input="name = ($event.target as HTMLInputElement).value"
/>
<span id="reload-name-hint" class="field-hint">{{ hint }}</span>
<span v-if="errors['name']" id="reload-name-error" class="field-error">
{{ errors['name'] }}
</span>
</div>
<p v-else-if="standing === 'confirm'">
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 class="actions">
<button class="button button-accent" type="submit" :disabled="starting || isBusy">
{{ isBusy ? 'Checking…' : isLast ? 'Finish' : 'Next' }}
</button>
<button
class="button button-secondary"
type="button"
:disabled="starting || !canBack"
@click="onBack()"
>
Back
</button>
</div>
</form>
<dl class="app-state">
<dt>status</dt>
<dd>{{ starting ? 'init' : status }}</dd>
<dt>saved</dt>
<dd>{{ STORAGE_KEY }}</dd>
</dl>
<p class="app-note">
<button
class="button button-secondary"
type="button"
:disabled="!mounted"
@click="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 class="app-live" role="status" aria-live="polite">{{ announcement }}</p>
</div>
</template>