From c256394c77533a0556daba2466f521174df8ba15 Mon Sep 17 00:00:00 2001 From: mathieu Date: Thu, 20 Aug 2026 20:23:31 +0200 Subject: [PATCH] Appliquer la mise a jour au demarrage et fermer les fuites de cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur l'application installee sur telephone, le bandeau restait sans effet et le chargement se figeait a 100 %. La capture montrait le bandeau SANS son CSS, alors que le script et sa regle sont arrivés dans le meme commit : ce n'etait pas une mise a jour qui ne s'applique pas, mais une mise a jour appliquee a moitie. - MapFallbackToFile n'heritait pas des StaticFileOptions : « / », le start_url de la PWA, repartait sans Cache-Control (mesure), donc en cache heuristique. A1 avait couvert tous les fichiers statiques sauf celui-la. - register('service-worker.js') se resolvait contre l'URL du document et non contre : une ouverture sur une route profonde visait /souhaits/service-worker.js, qui repond 404 (verifie). Tout le hors-ligne tombait alors en silence. - clients.claim() et un rechargement force borne a une fois par session donnent un filet a la chaine SKIP_WAITING -> controllerchange -> reload. - Une version prete dans les 10 s suivant l'ouverture s'applique seule, sans bandeau ; au-dela on repasse par le clic, pour ne pas arracher une saisie. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 87 +++++++++++++++++ MaBibli.Api/Program.cs | 8 +- MaBibli.Client/wwwroot/js/mise-a-jour.js | 97 +++++++++++++++---- .../wwwroot/service-worker.published.js | 6 ++ 4 files changed, 178 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 261c684..fa0d2c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2056,3 +2056,90 @@ accents et à la casse, cohérente avec le reste de l'application sans dupliquer champ n'apparaît que s'il y a plus d'un prêt en cours — avec un seul prêt, filtrer n'apprendrait rien, même raison que les lignes de filtre qui se cachent déjà ailleurs dans le catalogue quand elles seraient inutiles. + +## La mise à jour s'applique seule au démarrage — corrigé le 2026-08-20 + +Défaut remonté en usage sur l'**application installée sur téléphone** : bandeau « Une nouvelle +version est disponible », clic sans effet, chargement figé à **100 %**, puis un menu principal +d'une version antérieure au lot d'interface du 2026-08-18 (pas d'onglets en bas, filtres +dépliés, « Saisie manuelle » au catalogue). + +### Ce que la capture d'écran prouve, et qu'aucune supposition ne remplaçait + +Le bandeau s'affichait **sans son CSS** : texte brut en haut de l'écran, bouton par défaut, alors +que `#mb-maj` le pose en barre fixe **en bas**, fond bleu, bouton jaune. Or `js/mise-a-jour.js` +et la règle `#mb-maj` de `css/app.css` sont arrivés dans le **même commit** (`ebc5f95`). Le JS +était donc là, son CSS non — pendant que le cercle de chargement, issu du même `app.css`, était +bien stylé. + +⚠️ **Le symptôme n'était donc pas « la mise à jour ne s'applique pas » mais « une mise à jour +s'est appliquée à moitié ».** Un blocage à 100 % est la signature exacte d'un assemblage +dépareillé : tout est téléchargé, le manifeste de démarrage et les `.wasm` ne concordent pas, +l'application ne démarre jamais. Chercher un défaut dans le bouton aurait manqué la cause. + +### Le fallback n'hérite pas des `StaticFileOptions` — mesuré + +Le lot A1 avait posé `Cache-Control: no-cache, must-revalidate` sur `UseStaticFiles`. Mesuré sur +un publish du code de l'époque : + +| Requête | Cache-Control | +|---|---| +| `/index.html` | `no-cache, must-revalidate` ✅ | +| `/_framework/*` | `no-cache` (posé par `UseBlazorFrameworkFiles`) ✅ | +| **`/`** | **aucun** ❌ | + +`MapFallbackToFile` a son **propre pipeline** et ne passe pas par les options du middleware +statique. Or `/` est le **`start_url` de la PWA**, la seule URL qu'ouvre l'application installée : +elle repartait en cache heuristique du navigateur (une fraction de l'âge du fichier), donc +potentiellement périmée pendant des jours. A1 avait couvert tous les fichiers statiques **sauf le +seul qui compte pour une PWA installée**. La coquille HTML décide de tout le reste : la servir +périmée suffit à mélanger deux versions sur l'appareil. + +Correction : `app.MapFallbackToFile("index.html", optionsFichiersStatiques)`. Vérifié après +correction — `/`, `/livres/3` et `/souhaits` portent tous l'en-tête. + +### L'enregistrement se résolvait contre l'URL du document, pas contre `` + +`register('service-worker.js')` se résout contre l'**URL du document**, et **non** contre +``. Ouvrir l'application sur une route profonde visait donc +`/souhaits/service-worker.js` — vérifié : **404**, l'extension empêchant le fallback SPA de +répondre. L'enregistrement échouait alors silencieusement, **et avec lui tout le hors-ligne**. +Le chemin est désormais absolu, avec `scope: '/'` explicite. + +### Trois maillons sans filet + +`SKIP_WAITING` → `skipWaiting()` → `controllerchange` → `reload()`. Si un maillon manquait — un +worker en attente antérieur au gestionnaire de message, une reprise en main qui n'a pas lieu — le +bouton se grisait et **rien ne se passait, sans que rien ne le dise**. Deux ajouts : + +- `self.clients.claim()` dans `onActivate` : `skipWaiting()` est censé reprendre les pages seul, + mais c'est cette reprise qui déclenche `controllerchange`, donc le rechargement ; +- un rechargement **forcé** 5 s après `SKIP_WAITING` si le contrôleur n'a pas changé. + +⚠️ **Le rechargement forcé est borné à une fois par session** (`sessionStorage`), et **renonce** +si le stockage est refusé (navigation privée). Sans ce garde-fou, une version qui n'arrive pas à +s'activer transformerait une mise à jour ratée en **boucle de rechargement**, c'est-à-dire en +application inutilisable — bien pire que le défaut d'origine. + +### ⚠️ Le clic n'est plus obligatoire — décision inversée, et pourquoi + +CLAUDE.md actait « le message ne vient que de `js/mise-a-jour.js`, après un clic explicite : +jamais tout seul, pour ne pas mélanger deux versions au milieu d'une session ». Le raisonnement +reste juste — recharger sous une saisie en cours fait perdre le formulaire — **mais il ne vaut +que pour une session déjà entamée**. + +D'où `FENETRE_DEMARRAGE` (10 s) : une version prête dans les premières secondes après l'ouverture +s'applique **toute seule, sans bandeau** ; au-delà, on repasse par le bandeau. Ouvrir +l'application depuis l'écran d'accueil du téléphone tombe toujours dans cette fenêtre — c'est +exactement le cas qui ne se mettait plus à jour, et c'est celui où l'interruption ne coûte rien. + +### Ce qui n'est pas vérifiable ici, et qu'il faut confirmer sur le téléphone + +Le navigateur d'automatisation **n'enregistre aucun service worker** (voir la section dédiée) : +la chaîne complète n'a donc pas pu être éprouvée en exécution. Ce qui **est** vérifié : les +en-têtes sur toutes les routes, le 404 du chemin relatif, le démarrage de l'application avec le +nouveau script, et les 441 tests. + +⚠️ **Un appareil déjà dans l'état dépareillé ne se répare pas tout seul** : son cache mélangé +précède le correctif. Il faut vider les données du site (ou désinstaller puis réinstaller la +PWA) **une fois**. Le correctif empêche d'y retomber, il ne défait pas ce qui est déjà en place. diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index 12220c7..b6f9c3e 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -99,6 +99,12 @@ app.MapRevuesEndpoints(); app.MapBibliographieEndpoints(); app.MapIdentiteEndpoints(); -app.MapFallbackToFile("index.html"); +// ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées +// ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA, +// la seule URL qu'ouvre l'application installée — repartait sans aucun Cache-Control, donc en +// cache heuristique du navigateur. Mesuré : /index.html portait bien l'en-tête, « / » non. +// C'est la coquille HTML qui décide de tout le reste : la servir périmée suffit à mélanger deux +// versions sur l'appareil. +app.MapFallbackToFile("index.html", optionsFichiersStatiques); app.Run(); diff --git a/MaBibli.Client/wwwroot/js/mise-a-jour.js b/MaBibli.Client/wwwroot/js/mise-a-jour.js index 0c445e8..cd4cce8 100644 --- a/MaBibli.Client/wwwroot/js/mise-a-jour.js +++ b/MaBibli.Client/wwwroot/js/mise-a-jour.js @@ -1,4 +1,4 @@ -// Enregistrement du service worker et bandeau de mise à jour. +// Enregistrement du service worker, application automatique au démarrage, bandeau en secours. // // Pourquoi ce fichier existe (et pourquoi il n'est pas qu'une ligne `register(...)`) : // @@ -7,11 +7,16 @@ // empêche de servir éternellement une version périmée. Le mécanisme de Blazor fonctionne, // mais il est silencieux et différé : le nouveau worker attend que TOUS les onglets de // l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que -// personne comprenne pourquoi. D'où un bandeau explicite, avec un bouton qui l'applique. +// personne comprenne pourquoi. // // 2. `navigator.serviceWorker` n'existe pas en contexte non sécurisé (http sur une IP locale). // L'appeler sans vérification lève une TypeError qui casse le script — et fait croire à un // défaut de la PWA alors que c'est le contexte qui n'est pas éligible. +// +// ⚠️ Le clic n'est plus obligatoire (décidé le 2026-08-20, après un blocage constaté sur +// l'application installée sur téléphone). CLAUDE.md actait « jamais tout seul » pour ne pas +// recharger sous une saisie en cours ; la raison reste valable, mais elle ne vaut que pour une +// session déjà entamée. Voir FENETRE_DEMARRAGE ci-dessous. (function () { 'use strict'; @@ -23,49 +28,103 @@ return; } - // Le rechargement n'a lieu que si l'utilisateur a cliqué : un changement de contrôleur - // survient aussi à la toute première installation, et recharger la page à ce moment-là - // serait un clignotement inexplicable. - let demandee = false; + // Fenêtre pendant laquelle une nouvelle version s'applique TOUTE SEULE, sans rien demander. + // Au-delà, l'utilisateur est en train de faire quelque chose (une fiche à moitié saisie, un + // prêt en cours d'enregistrement) : on repasse par le bandeau, pour ne pas lui arracher son + // travail. Ouvrir l'application depuis l'écran d'accueil du téléphone tombe toujours dans + // cette fenêtre — c'est exactement le cas qui ne se mettait plus à jour. + var FENETRE_DEMARRAGE = 10000; + + // Si la reprise en main n'a pas lieu, on recharge quand même : le pire scénario est de + // laisser l'utilisateur devant un bouton grisé qui ne fait rien. + var DELAI_REPRISE = 5000; + + // Une seule reprise forcée par session : si la nouvelle version n'arrive pas à s'activer, + // recharger en boucle transformerait une mise à jour ratée en application inutilisable. + var CLE_FORCE = 'mb-maj-forcee'; + + var demandee = false; + var recharge = false; + + function recharger() { + if (recharge) return; + recharge = true; + window.location.reload(); + } + + function appliquer(enAttente) { + demandee = true; + // Le worker en attente prend la main sans qu'on ait à fermer tous les onglets. + enAttente.postMessage({ type: 'SKIP_WAITING' }); + + // Filet. `controllerchange` devrait suivre ; s'il ne vient pas (worker en attente issu + // d'une version antérieure au gestionnaire SKIP_WAITING, reprise en main qui échoue), + // rien ne se passerait du tout et le bouton resterait grisé pour toujours. + window.setTimeout(function () { + if (recharge) return; + var dejaForcee = false; + try { + dejaForcee = window.sessionStorage.getItem(CLE_FORCE) === '1'; + window.sessionStorage.setItem(CLE_FORCE, '1'); + } catch (e) { + // Navigation privée ou stockage refusé : on préfère ne pas forcer plutôt que + // risquer une boucle de rechargement qu'on ne saurait plus arrêter. + dejaForcee = true; + } + if (!dejaForcee) recharger(); + }, DELAI_REPRISE); + } function proposerLaMiseAJour(enAttente) { if (document.getElementById('mb-maj')) return; - const barre = document.createElement('div'); + var barre = document.createElement('div'); barre.id = 'mb-maj'; barre.setAttribute('role', 'status'); barre.textContent = 'Une nouvelle version de MaBibli est disponible. '; - const bouton = document.createElement('button'); + var bouton = document.createElement('button'); bouton.type = 'button'; bouton.textContent = 'Mettre à jour'; bouton.addEventListener('click', function () { bouton.disabled = true; - demandee = true; - // Le worker en attente prend la main sans qu'on ait à fermer tous les onglets. - enAttente.postMessage({ type: 'SKIP_WAITING' }); + bouton.textContent = 'Mise à jour…'; + appliquer(enAttente); }); barre.appendChild(bouton); document.body.appendChild(barre); } - navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' }) + // Au démarrage : on applique sans demander. Plus tard : on propose. + function traiter(enAttente) { + if (performance.now() < FENETRE_DEMARRAGE) { + appliquer(enAttente); + } else { + proposerLaMiseAJour(enAttente); + } + } + + // ⚠️ Chemin ABSOLU, et scope explicite. `register('service-worker.js')` se résout contre + // l'URL du DOCUMENT et non contre : ouvrir l'application sur une route + // profonde (/souhaits, /livres/3) visait /souhaits/service-worker.js, que le serveur ne sert + // pas — l'enregistrement échouait alors silencieusement, et avec lui tout le hors-ligne. + navigator.serviceWorker.register('/service-worker.js', { scope: '/', updateViaCache: 'none' }) .then(function (enregistrement) { // Un worker déjà installé attendait peut-être depuis la visite précédente. if (enregistrement.waiting && navigator.serviceWorker.controller) { - proposerLaMiseAJour(enregistrement.waiting); + traiter(enregistrement.waiting); } enregistrement.addEventListener('updatefound', function () { - const nouveau = enregistrement.installing; + var nouveau = enregistrement.installing; if (!nouveau) return; nouveau.addEventListener('statechange', function () { // `controller` non nul = ce n'est pas la première installation, donc il y a // bien une version précédente à remplacer. if (nouveau.state === 'installed' && navigator.serviceWorker.controller) { - proposerLaMiseAJour(nouveau); + traiter(nouveau); } }); }); @@ -80,10 +139,10 @@ console.error("Échec de l'enregistrement du service worker :", erreur); }); - let recharge = false; + // Le rechargement n'a lieu que si la mise à jour a été demandée — automatiquement ou par un + // clic. Un changement de contrôleur survient aussi à la toute première installation, et + // recharger la page à ce moment-là serait un clignotement inexplicable. navigator.serviceWorker.addEventListener('controllerchange', function () { - if (!demandee || recharge) return; - recharge = true; - window.location.reload(); + if (demandee) recharger(); }); })(); diff --git a/MaBibli.Client/wwwroot/service-worker.published.js b/MaBibli.Client/wwwroot/service-worker.published.js index ae8705a..1d16a12 100644 --- a/MaBibli.Client/wwwroot/service-worker.published.js +++ b/MaBibli.Client/wwwroot/service-worker.published.js @@ -45,6 +45,12 @@ async function onActivate(event) { await Promise.all(cacheKeys .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) .map(key => caches.delete(key))); + + // Prendre la main sur les pages déjà ouvertes. skipWaiting() est censé le faire seul, mais + // c'est ce qui déclenche `controllerchange`, donc le rechargement côté page : sans reprise + // effective, le bouton « Mettre à jour » se grise et il ne se passe plus rien — symptôme + // observé sur téléphone. Cet appel est sans effet quand la reprise a déjà eu lieu. + await self.clients.claim(); } async function onFetch(event) {