Dans l'article précédent, je racontais pourquoi j'ai construit Mes mots, une appli de communication par pictogrammes pour un enfant qui ne parle pas encore. Une des contraintes du projet, c'est qu'elle doit fonctionner en mode avion, sur une tablette qui ne verra pas forcément le wifi pendant des jours. Cet article-ci ne parle que de ça : comment on construit un truc vraiment hors-ligne, et surtout comment il se met à jour sans réseau permanent.
« Offline-first », dans la plupart des projets qui portent l'étiquette, veut dire autre chose que ce que ça veut dire ici. Ça vaut la peine de clarifier avant d'entrer dans le code.
Le malentendu autour d'« offline-first »
La majorité des applications qu'on qualifie d'offline-first ont en réalité un serveur derrière. Un Trello, un Google Docs, un client mail : ils mettent en cache, laissent écrire hors-ligne, puis synchronisent et réconcilient les conflits quand le réseau revient. C'est un vrai problème d'ingénierie, résolution de conflits, vecteurs d'horloge, fusion à trois voies, et une bonne partie de la doc sur le sujet parle de ça.
Mes mots n'a pas ce problème, parce qu'elle n'a pas de serveur du tout. Il n'y a rien à synchroniser, parce qu'il n'y a personne en face. La seule chose qui « arrive » depuis le réseau, occasionnellement, c'est une nouvelle version du code de l'appli elle-même, pas des données. C'est une catégorie beaucoup plus rare : pas offline-first avec repli réseau, mais offline tout court, avec une mise à jour de temps en temps.
Ça enlève une classe entière de problèmes (pas de conflits, pas de jeton d'auth à rafraîchir, pas d'état de synchronisation partiel à afficher). Ça en amène une autre, plus étroite et plus bizarre : faire arriver une mise à jour de code sans jamais perturber ce que l'utilisateur est en train de faire, alors qu'on n'a littéralement aucun moyen de lui parler autrement que par l'écran qu'il utilise déjà.
Ce qu'il faut mettre en cache, et le fichier qu'on oublie
Le projet utilise le plugin vite-plugin-pwa, qui génère un service worker via Workbox à partir d'une simple config déclarative. Le point de départ, c'est la liste des fichiers à précacher :
workbox: {
// les MP3 des phrases doivent être en cache : sans eux l'app est muette hors ligne
globPatterns: ['**/*.{js,css,html,png,svg,woff2,mp3}'],
// un cache d'une version précédente a déjà servi un bundle disparu du disque, ce qui
// masquait entièrement une fonction livrée : la famille resterait sur une vieille
// version sans le savoir, et sans moyen de s'en apercevoir
cleanupOutdatedCaches: true,
},
Le piège tient dans une extension oubliée. Le motif {js,css,html,png,svg,woff2}
attrape tout ce qu'un build front classique produit. Sauf que cette appli parle : chaque case
dit son mot en jouant un fichier .mp3. Sans mp3 dans la liste, l'appli
s'installe très bien, s'ouvre hors-ligne sans erreur, les cases s'affichent, tout a l'air normal.
Et rien ne sort du haut-parleur. Pas d'exception dans la console, pas d'écran d'erreur : juste
un silence qu'un parent découvrirait en mode avion, au pire moment possible.
C'est la nature du bug de cache dans une PWA : il ne casse rien, il retire discrètement une fonctionnalité. Le seul filet, c'est de lister explicitement tout ce dont l'app a besoin pour fonctionner sans réseau, pas seulement le code.
Un domaine racine, un sous-dossier, et un écran blanc sans erreur
L'appli tourne à deux endroits : sur la tablette de la famille, servie à la racine d'un domaine
(anime-sanctuary.net), et en démo publique sur GitHub Pages, sous
/mes-mots/. Un service worker et ses assets précachés sont scopés à une URL de
base précise, et une PWA compilée pour la racine cherche ses fichiers un cran trop haut quand
elle est servie depuis un sous-dossier.
/**
* La tablette sert l'application à la racine de son domaine. La démo publique vit sous
* `/mes-mots/` sur GitHub Pages, et sans ce préfixe elle chercherait ses fichiers un cran
* trop haut : page blanche, sans erreur lisible. `BASE_PUBLIQUE` n'est posé que pour elle.
*/
const BASE = process.env.BASE_PUBLIQUE ?? '/'
Une variable d'environnement, lue une seule fois au build, tranche entre les deux cas. La leçon derrière, ce n'est pas la variable elle-même, c'est le mode d'échec : un mauvais chemin de base ne lève pas d'exception propre à lire dans les devtools, il produit une page blanche. Le genre de bug qu'on ne débogue pas à distance, sur la tablette de quelqu'un qui n'a pas de terminal.
Une mise à jour qui arrive sans qu'on la demande
registerType: 'autoUpdate' dans la config du plugin veut dire que le service
worker télécharge la nouvelle version dès qu'il détecte du réseau, l'installe en arrière-plan,
et prend la main tout seul, sans bannière « une mise à jour est disponible » à cliquer. Ce que
ce mode ne fait pas dire, c'est que la page déjà ouverte continue de tourner avec l'ancien
JavaScript jusqu'à un rechargement complet.
Le navigateur expose un événement pour le savoir : controllerchange, déclenché
quand un nouveau service worker vient de prendre le contrôle de la page.
/**
* Le nouveau service worker prend la main tout seul, mais la page affichée reste l'ancienne
* jusqu'à un rechargement : il fallait donc charger deux fois pour voir une correction, et
* l'écoute ne vivait que dans l'espace parents, absent de l'écran de l'enfant. Ici elle
* commence au démarrage. `controller` n'existe pas à la toute première installation, qui
* passerait sinon pour une mise à jour.
*/
const nouvelleVersionPrete = ref(false)
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
nouvelleVersionPrete.value = true
})
}
Le commentaire raconte une vraie régression corrigée : cette écoute vivait au départ dans
l'écran parents, jamais visible depuis l'écran de l'enfant, celui qui tourne réellement toute
la journée. Résultat, une mise à jour prise par le service worker n'était détectée que si un
parent ouvrait son espace, ce qui pouvait mettre des jours. Le garde-fou sur
controller évite en retour de confondre la toute première installation (où il
n'y a pas encore de contrôleur) avec une vraie mise à jour.
Ne pas recharger une case en train de parler
Détecter qu'une mise à jour est prête ne dit pas quand l'appliquer. Recharger la page tout de suite couperait un mot en train d'être dit, effacerait une phrase en cours de composition sur la bande, ou emporterait le formulaire à moitié rempli d'un parent dans l'espace réglages. Pour un enfant qui ne comprend pas pourquoi l'écran a changé, chacun de ces cas est une mauvaise surprise.
La solution, c'est de définir un état de repos et de n'appliquer la mise à jour qu'à ce moment-là :
/**
* Le rechargement attend que la tablette ne serve à personne : il couperait sinon un mot en
* train d'être dit, effacerait une phrase en cours de composition, ou emporterait le
* formulaire à moitié rempli d'un parent. Au repos il passe inaperçu.
*/
const tabletteAuRepos = computed(
() =>
mode.value === 'enfant' &&
idCaseQuiParle.value === null &&
!litLaPhrase.value &&
phrase.value.length === 0,
)
watch([nouvelleVersionPrete, tabletteAuRepos], () => {
if (nouvelleVersionPrete.value && tabletteAuRepos.value) location.reload()
})
Quatre conditions doivent être vraies en même temps : l'écran enfant est affiché (pas l'espace parents), aucune case n'est en train de parler, la lecture de la bande de phrase n'est pas en cours, et la phrase composée est vide. Le rechargement attend patiemment que ces quatre feux passent au vert, et ne se déclenche que là. Sur une appli qui tourne toute la journée sans qu'on la ferme jamais, ce moment finit toujours par arriver, souvent en quelques minutes.
C'est une règle qui se généralise au-delà de ce projet : une mise à jour silencieuse en arrière-plan est inoffensive pour un blog ou un dashboard qu'on peut rafraîchir sans y penser. Elle devient dangereuse dès que l'état de la page a de la valeur, un formulaire en cours, un média en lecture, une saisie non sauvegardée. Il faut alors un point de sécurité explicite, pas juste un minuteur.
Savoir quelle version tourne, sans ouvrir les devtools
Dernier détail, minuscule dans le code, décisif dans l'usage : chaque build imprime sa propre date de construction.
/** Date de construction, affichée dans l'espace parents : « quelle version tourne ? » doit
* se répondre en regardant l'écran, pas en fouillant un cache de service worker. */
const VERSION_CONSTRUITE = new Date().toISOString().slice(0, 16).replace('T', ' ')
Un développeur diagnostique un problème de cache en ouvrant l'onglet Application des devtools. Un parent n'ouvrira jamais cet onglet, et ne devrait pas avoir à le faire. Afficher la version en clair dans l'espace parents transforme une question de debug (« est-ce que le service worker a pris la dernière version ? ») en une question que n'importe qui peut répondre en regardant l'écran.
Conclusion
Le réseau, en développement web, on a appris à s'en méfier : timeouts, retries, états de chargement, tout un vocabulaire pour parler de son absence. Le silence d'une mise à jour qui arrive sans qu'on la voie passer, beaucoup moins. Construire une appli qui ne demande jamais le réseau n'élimine pas la complexité, elle la déplace vers un endroit auquel on pense rarement : le moment exact où il devient sûr de remplacer le sol sous les pieds de quelqu'un qui est en train de s'en servir.