APOLLO VISION LABS

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.

  • Tours for start(), resume and the tour lifecycle.
  • Checklist for items, ticking and dismissal.
  • Hotspots for markers, seen and the restored gate.
  • API reference for the exact signatures.