Persistence
How guide stores tour, checklist and hotspot progress, what the GuideStorage contract requires, and what happens when a read is slow.
Without a storage prop, nothing is remembered. A reload restarts a tour on its first step and
shows a checklist with no item ticked. Persistence is opt in, and it is one interface with two
methods.
The GuideStorage contract
interface GuideStorage {
read<T>(key: string): Promise<T | null>
write<T>(key: string, value: T): Promise<void>
}
Both methods are generic over the stored value and take a plain string key. The storage does not know what it holds. It reads a value by key and writes a value by key, and that is the whole contract.
read resolving to null is the normal first visit case, not an error. A rejected promise is
tolerated: the provider logs one warning and carries on from the beginning, but rejecting is not
how you say “nothing stored”.
The three key namespaces
GuideProvider reads and writes tour progress under tour:<id>. ChecklistProvider reads and
writes checklist progress under checklist:<id>. HotspotProvider reads and writes which hotspots
have been opened under the single key hotspots:seen.
// written by GuideProvider under `tour:product`
interface TourProgress {
status: 'in-progress' | 'completed'
stepIndex: number
}
// written by ChecklistProvider under `checklist:onboarding`
interface ChecklistProgress {
completed: string[]
dismissed: boolean
}
// written by HotspotProvider under `hotspots:seen`
interface HotspotsProgress {
seen: string[]
}
The caller builds the namespace, not the storage. That is what lets one storage instance serve all three providers: pass the same object to each and the three kinds of progress land at different keys inside it. A storage that namespaced on its own behalf would have to know which kind of value it was holding, and every new kind of progress would mean a new implementation.
const storage = createBrowserStorage('my-app')
<GuideProvider tours={[productTour]} storage={storage}>
<ChecklistProvider checklists={[onboardingChecklist]} storage={storage}>
<HotspotProvider hotspots={[shareHotspot]} storage={storage}>
{children}
</HotspotProvider>
</ChecklistProvider>
</GuideProvider>
The two supplied implementations
import { createBrowserStorage, createMemoryStorage } from '@apollovisionlabs/guide-core'
createMemoryStorage() // a Map, for the lifetime of the page
createMemoryStorage({ 'tour:product': { status: 'in-progress', stepIndex: 2 } })
createBrowserStorage() // localStorage, keys prefixed "guide:"
createBrowserStorage('my-app') // keys prefixed "my-app:"
createMemoryStorage holds a Map, seeded from an optional record. It is the right choice in
tests, so runs do not leak progress into each other.
createBrowserStorage writes JSON to localStorage under <namespace>:<key>. It returns null
rather than throwing when there is no window or no localStorage, so a server render is safe, and
it swallows write failures: a blocked store or an exceeded quota costs you persistence, not the
tour. A value that fails to parse reads back as null.
Neither implementation makes a network call. If progress has to follow a user across devices or
across a shared workstation, you write the implementation, because localStorage belongs to the
browser profile and not to the person signed in.
Neither implementation validates
Both are dumb pipes. Whatever was written comes back, and whatever ended up at that key by other means comes back too: a value written by an older version of your own code, a browser extension, a hand edit in devtools.
Validation happens at the read site instead. Three guards are exported for it, and the providers use the same ones:
import {
isChecklistProgress,
isHotspotsProgress,
isTourProgress,
type TourProgress,
} from '@apollovisionlabs/guide-core'
const stored = await storage.read<unknown>('tour:product')
const progress: TourProgress | null = isTourProgress(stored) ? stored : null
isTourProgress requires an integer stepIndex of zero or more and a status of exactly
'in-progress' or 'completed'. isChecklistProgress requires a completed array of strings and
a boolean dismissed. isHotspotsProgress requires a seen array of strings. Anything else is
rejected and treated as nothing stored.
Read <unknown> rather than <TourProgress> when you intend to check the result. Asking read for
a type it cannot verify only moves the assumption earlier.
Writing your own storage
A server backed implementation provides those same two methods. Everything else is yours: the route, the auth, the table.
import type { GuideStorage } from '@apollovisionlabs/guide-core'
export function createApiStorage(baseUrl: string): GuideStorage {
return {
async read<T>(key: string): Promise<T | null> {
const response = await fetch(`${baseUrl}/guide/${encodeURIComponent(key)}`, {
credentials: 'include',
})
// Nothing stored yet is the first visit case, and it resolves to null.
if (response.status === 404) return null
if (!response.ok) throw new Error(`guide storage read failed: ${response.status}`)
return (await response.json()) as T
},
async write<T>(key: string, value: T): Promise<void> {
await fetch(`${baseUrl}/guide/${encodeURIComponent(key)}`, {
method: 'PUT',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(value),
})
},
}
}
Two things to get right.
Scope the key to the user on the server side. The key is all the library gives you, and it
carries a tour id, a checklist id, or the fixed hotspots:seen string, never an identity. The
signed in user comes from your session.
Store the value opaquely. The library may persist a shape you did not anticipate at a key you
did not anticipate. A JSON column keyed by (user, key) ages better than columns named after
stepIndex.
When each provider reads and writes
GuideProvider reads once, inside start(), and only when a storage is present, from was not
given and resume is not false. The stored progress is used only when its status is
'in-progress': a tour recorded as completed starts again from its first step. A read that rejects
warns once and starts from the beginning.
It writes on every step change and on completion, as { status, stepIndex }, where status is
'in-progress' while the tour runs and 'completed' when it finishes. A paused or stopped tour
writes nothing, so the last recorded position stands.
ChecklistProvider reads once on mount, one key per checklist, each read running concurrently with
the others. It writes the whole ChecklistProgress for a checklist on every tick, untick, dismissal
and reset.
HotspotProvider reads once on mount, the single hotspots:seen key. It writes the whole
HotspotsProgress every time a hotspot is opened, and on reset().
All three providers warn once per provider on a storage failure and keep going. Nothing about persistence can stop a tour.
What a slow read does, and what it does not
ChecklistProvider and HotspotProvider no longer render their initial, pre-restore state while
their own read is still in flight. Checklist, ChecklistLauncher and Hotspots each wait for
restored (useChecklist(checklistId).restored, useHotspots().restored) before drawing anything,
so a checklist already dismissed or partly completed in storage, or a hotspot already marked seen,
never flashes the wrong state for one paint before the real one lands. With a server backed storage
this means the checklist or the hotspot markers appear later than they used to, once the read
settles, rather than appearing at once and then jumping.
Once restored is true, a later change still merges rather than replaces. The stored result is
merged entry by entry with whatever happened on screen in the meantime, not before it: for each
checklist, completed becomes the union of the live entries and the stored ones, and dismissed is
true if either side says so; for hotspots, seen is the union of the two. So a tick or an open made
between the read landing and any later interaction is never in question, because there is nothing
left to race against once restored flips.
The merge cannot subtract, and the code says so rather than hiding it. Two moves lose against a
read still in flight, before restored becomes true:
- unticking an item that the stored value has ticked, or calling
reset()on a checklist; reset()on the hotspots.
Both are undone when the read lands, because the union puts the stored values back. The window is
bounded by that single read at mount, and it does not reopen: later changes to the checklists or
hotspots prop do not trigger another read. If a deliberate clearing has to survive the window in
your application, sequence it against restored rather than firing it blind at mount.
GuideProvider has no such window. Its read is awaited inside start() before the tour state
changes, so nothing is on screen to be overwritten.
If you write your own GuideStorage, this is what a slow or unreliable read now costs. A read
that hangs and never settles keeps that one checklist, or the hotspots, permanently hidden behind
restored, since nothing there ever flips it to true: a read that eventually fails, by
rejecting, is the safer failure, because the provider catches the rejection and sets restored to
true anyway, so what you see is a checklist or a set of hotspots that came up with nothing stored,
not one that never comes up at all.
Related
- Tours for
start(),resumeand the tour lifecycle. - Checklist for items, ticking and dismissal.
- Hotspots for markers,
seenand therestoredgate. - API reference for the exact signatures.