Persistance
Comment guide conserve la progression des visites, des checklists et des hotspots, ce qu'exige le contrat GuideStorage, et ce qui se passe quand une lecture est lente.
Sans prop storage, rien n’est conservé. Un rechargement redémarre une visite à sa première étape
et affiche une checklist sans aucune case cochée. La persistance s’active à la demande, et tient
dans une interface de deux méthodes.
Le contrat GuideStorage
interface GuideStorage {
read<T>(key: string): Promise<T | null>
write<T>(key: string, value: T): Promise<void>
}
Les deux méthodes sont génériques sur la valeur stockée et prennent une clé qui est une simple chaîne. Le stockage ignore ce qu’il contient. Il lit une valeur par clé et écrit une valeur par clé : c’est tout le contrat.
Une lecture qui résout sur null est le cas normal de la première visite, pas une erreur. Une
promesse rejetée est tolérée : le provider émet un avertissement unique et repart du début, mais
rejeter n’est pas la façon de dire « rien n’est stocké ».
Les trois espaces de clés
GuideProvider lit et écrit la progression d’une visite sous tour:<id>. ChecklistProvider lit
et écrit la progression d’une checklist sous checklist:<id>. HotspotProvider lit et écrit les
hotspots déjà ouverts sous la seule clé hotspots:seen.
// écrit par GuideProvider sous `tour:product`
interface TourProgress {
status: 'in-progress' | 'completed'
stepIndex: number
}
// écrit par ChecklistProvider sous `checklist:onboarding`
interface ChecklistProgress {
completed: string[]
dismissed: boolean
}
// écrit par HotspotProvider sous `hotspots:seen`
interface HotspotsProgress {
seen: string[]
}
C’est l’appelant qui construit l’espace de nommage, pas le stockage. C’est ce qui permet à une seule instance de servir les trois providers : passez le même objet aux trois et les trois progressions atterrissent à des clés différentes. Un stockage qui préfixerait de lui-même devrait savoir quel type de valeur il détient, et chaque nouvelle forme de progression imposerait une nouvelle implémentation.
const storage = createBrowserStorage('my-app')
<GuideProvider tours={[productTour]} storage={storage}>
<ChecklistProvider checklists={[onboardingChecklist]} storage={storage}>
<HotspotProvider hotspots={[shareHotspot]} storage={storage}>
{children}
</HotspotProvider>
</ChecklistProvider>
</GuideProvider>
Les deux implémentations fournies
import { createBrowserStorage, createMemoryStorage } from '@apollovisionlabs/guide-core'
createMemoryStorage() // une Map, le temps de vie de la page
createMemoryStorage({ 'tour:product': { status: 'in-progress', stepIndex: 2 } })
createBrowserStorage() // localStorage, clés préfixées « guide: »
createBrowserStorage('my-app') // clés préfixées « my-app: »
createMemoryStorage tient une Map, initialisée à partir d’un enregistrement optionnel. C’est le
bon choix en test, pour que les exécutions ne se transmettent pas de progression.
createBrowserStorage écrit du JSON dans localStorage sous <namespace>:<key>. Il renvoie null
au lieu de lever une exception quand il n’y a ni window ni localStorage, donc un rendu serveur ne
casse rien, et il absorbe les échecs d’écriture : un stockage bloqué ou un quota dépassé vous coûte
la persistance, pas la visite. Une valeur qui ne se parse pas est relue comme null.
Aucune des deux ne fait d’appel réseau. Si la progression doit suivre l’utilisateur d’un appareil à
l’autre, ou sur un poste partagé, c’est à vous d’écrire l’implémentation : localStorage appartient
au profil du navigateur, pas à la personne connectée.
Aucune des deux ne valide
Ce sont des tuyaux. Ce qui a été écrit revient, et ce qui s’est retrouvé à cette clé par un autre chemin revient aussi : une valeur écrite par une version antérieure de votre propre code, une extension de navigateur, une modification à la main dans les outils de développement.
La validation se fait au moment de la lecture. Trois gardes sont exportées pour cela, et les providers utilisent les mêmes :
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 exige un stepIndex entier supérieur ou égal à zéro et un status valant
exactement 'in-progress' ou 'completed'. isChecklistProgress exige un tableau completed de
chaînes et un booléen dismissed. isHotspotsProgress exige un tableau seen de chaînes. Tout le
reste est rejeté et traité comme s’il n’y avait rien.
Lisez en <unknown> plutôt qu’en <TourProgress> quand vous comptez vérifier le résultat.
Demander à read un type qu’il ne peut pas garantir ne fait que déplacer l’hypothèse plus tôt.
Écrire son propre stockage
Une implémentation adossée à un serveur fournit les deux mêmes méthodes. Le reste vous appartient : la route, l’authentification, la 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',
})
// Rien de stocké, c'est la première visite : on résout sur 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),
})
},
}
}
Deux points à ne pas rater.
Rattachez la clé à l’utilisateur côté serveur. La clé est tout ce que la bibliothèque vous
donne, et elle porte un identifiant de visite, un identifiant de checklist, ou la chaîne fixe
hotspots:seen, jamais une identité. L’utilisateur connecté vient de votre session.
Stockez la valeur de façon opaque. La bibliothèque peut persister une forme que vous n’aviez pas
prévue, à une clé que vous n’aviez pas prévue. Une colonne JSON indexée par (utilisateur, clé)
vieillit mieux que des colonnes nommées d’après stepIndex.
Quand chaque provider lit et écrit
GuideProvider lit une fois, à l’intérieur de start(), et seulement si un stockage est fourni, si
from n’est pas donné et si resume ne vaut pas false. La progression stockée n’est utilisée que
si son status vaut 'in-progress' : une visite enregistrée comme terminée repart de sa première
étape. Une lecture qui échoue déclenche un avertissement unique et un départ du début.
Il écrit à chaque changement d’étape et à la fin, sous la forme { status, stepIndex }, avec
'in-progress' pendant la visite et 'completed' à l’arrivée. Une visite en pause ou arrêtée
n’écrit rien : la dernière position enregistrée reste en place.
ChecklistProvider lit une fois au montage, une clé par checklist, chaque lecture s’exécutant en
parallèle des autres. Il écrit toute la ChecklistProgress d’une checklist à chaque coche, décoche,
fermeture et réinitialisation.
HotspotProvider lit une fois au montage, la seule clé hotspots:seen. Il écrit toute la
HotspotsProgress à chaque ouverture d’un hotspot, et à chaque reset().
Les trois providers avertissent une seule fois par provider en cas d’échec du stockage et continuent. Rien dans la persistance ne peut arrêter une visite.
Ce qu’une lecture lente fait, et ce qu’elle ne fait pas
ChecklistProvider et HotspotProvider n’affichent plus leur état initial, antérieur à la
restauration, pendant que leur propre lecture est encore en vol. Checklist, ChecklistLauncher et
Hotspots attendent chacun restored (useChecklist(checklistId).restored,
useHotspots().restored) avant de dessiner quoi que ce soit, donc une checklist déjà fermée ou
partiellement complétée dans le stockage, ou un hotspot déjà marqué vu, n’affiche jamais le mauvais
état pendant une image avant que le vrai n’arrive. Avec un stockage adossé à un serveur, cela veut
dire que la checklist ou les marqueurs de hotspot apparaissent plus tard qu’avant, une fois la
lecture arrivée, au lieu d’apparaître tout de suite puis de sauter.
Une fois restored vrai, un changement ultérieur fusionne toujours au lieu de remplacer. Le
résultat stocké est fusionné entrée par entrée avec ce qui s’est passé à l’écran entre-temps, pas
avant : pour chaque checklist, completed devient l’union des entrées vivantes et des entrées
stockées, et dismissed vaut vrai si l’un des deux côtés le dit ; pour les hotspots, seen est
l’union des deux. Une coche ou une ouverture faite entre l’arrivée de la lecture et une interaction
ultérieure n’est donc jamais en jeu, puisqu’il n’y a plus rien à courir contre une fois restored
passé à vrai.
La fusion ne sait pas soustraire, et le code le dit au lieu de le masquer. Deux gestes perdent face
à une lecture encore en vol, avant que restored ne passe à vrai :
- décocher un élément que la valeur stockée a coché, ou appeler
reset()sur une checklist ; reset()sur les hotspots.
Les deux sont défaits à l’arrivée de la lecture, puisque l’union remet les valeurs stockées. La
fenêtre est bornée par cette unique lecture au montage et elle ne se rouvre pas : un changement
ultérieur de la prop checklists ou hotspots ne déclenche pas de relecture. Si un effacement
volontaire doit survivre à cette fenêtre dans votre application, séquencez-le après restored au
lieu de le lancer à l’aveugle au montage.
GuideProvider n’a pas cette fenêtre. Sa lecture est attendue à l’intérieur de start() avant que
l’état de la visite ne change : il n’y a rien à l’écran à écraser.
Si vous écrivez votre propre GuideStorage, voici ce que coûte désormais une lecture lente ou peu
fiable. Une lecture qui reste bloquée et ne se termine jamais garde cette checklist, ou les
hotspots, cachés en permanence derrière restored, puisque rien ne le fait alors passer à true.
Une lecture qui finit par échouer, en étant rejetée, est le mode de défaillance le moins grave : le
provider rattrape le rejet et fait passer restored à true quand même, donc ce qui s’affiche est
une checklist ou des hotspots partis de rien de stocké, pas des composants qui ne s’affichent
jamais.
Pour aller plus loin
- Visites guidées pour
start(),resumeet le cycle de vie d’une visite. - Checklist pour les éléments, la coche et la fermeture.
- Hotspots pour les marqueurs,
seenet la barrièrerestored. - Référence de l’API pour les signatures exactes.