Appliquer la mise a jour au demarrage et fermer les fuites de cache

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 <base> : 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 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-20 20:23:31 +02:00
co-authored by Claude Opus 5
parent 347e1ce7fd
commit c256394c77
4 changed files with 178 additions and 20 deletions
+87
View File
@@ -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 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 rien, même raison que les lignes de filtre qui se cachent déjà ailleurs dans le catalogue quand
elles seraient inutiles. 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 `<base>`
`register('service-worker.js')` se résout contre l'**URL du document**, et **non** contre
`<base href="/">`. 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.
+7 -1
View File
@@ -99,6 +99,12 @@ app.MapRevuesEndpoints();
app.MapBibliographieEndpoints(); app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints(); 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(); app.Run();
+78 -19
View File
@@ -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(...)`) : // 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, // 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 // 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 // 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). // 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 // 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. // 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 () { (function () {
'use strict'; 'use strict';
@@ -23,49 +28,103 @@
return; return;
} }
// Le rechargement n'a lieu que si l'utilisateur a cliqué : un changement de contrôleur // Fenêtre pendant laquelle une nouvelle version s'applique TOUTE SEULE, sans rien demander.
// survient aussi à la toute première installation, et recharger la page à ce moment-là // Au-delà, l'utilisateur est en train de faire quelque chose (une fiche à moitié saisie, un
// serait un clignotement inexplicable. // prêt en cours d'enregistrement) : on repasse par le bandeau, pour ne pas lui arracher son
let demandee = false; // 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) { function proposerLaMiseAJour(enAttente) {
if (document.getElementById('mb-maj')) return; if (document.getElementById('mb-maj')) return;
const barre = document.createElement('div'); var barre = document.createElement('div');
barre.id = 'mb-maj'; barre.id = 'mb-maj';
barre.setAttribute('role', 'status'); barre.setAttribute('role', 'status');
barre.textContent = 'Une nouvelle version de MaBibli est disponible. '; barre.textContent = 'Une nouvelle version de MaBibli est disponible. ';
const bouton = document.createElement('button'); var bouton = document.createElement('button');
bouton.type = 'button'; bouton.type = 'button';
bouton.textContent = 'Mettre à jour'; bouton.textContent = 'Mettre à jour';
bouton.addEventListener('click', function () { bouton.addEventListener('click', function () {
bouton.disabled = true; bouton.disabled = true;
demandee = true; bouton.textContent = 'Mise à jour…';
// Le worker en attente prend la main sans qu'on ait à fermer tous les onglets. appliquer(enAttente);
enAttente.postMessage({ type: 'SKIP_WAITING' });
}); });
barre.appendChild(bouton); barre.appendChild(bouton);
document.body.appendChild(barre); 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 <base href="/"> : 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) { .then(function (enregistrement) {
// Un worker déjà installé attendait peut-être depuis la visite précédente. // Un worker déjà installé attendait peut-être depuis la visite précédente.
if (enregistrement.waiting && navigator.serviceWorker.controller) { if (enregistrement.waiting && navigator.serviceWorker.controller) {
proposerLaMiseAJour(enregistrement.waiting); traiter(enregistrement.waiting);
} }
enregistrement.addEventListener('updatefound', function () { enregistrement.addEventListener('updatefound', function () {
const nouveau = enregistrement.installing; var nouveau = enregistrement.installing;
if (!nouveau) return; if (!nouveau) return;
nouveau.addEventListener('statechange', function () { nouveau.addEventListener('statechange', function () {
// `controller` non nul = ce n'est pas la première installation, donc il y a // `controller` non nul = ce n'est pas la première installation, donc il y a
// bien une version précédente à remplacer. // bien une version précédente à remplacer.
if (nouveau.state === 'installed' && navigator.serviceWorker.controller) { 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); 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 () { navigator.serviceWorker.addEventListener('controllerchange', function () {
if (!demandee || recharge) return; if (demandee) recharger();
recharge = true;
window.location.reload();
}); });
})(); })();
@@ -45,6 +45,12 @@ async function onActivate(event) {
await Promise.all(cacheKeys await Promise.all(cacheKeys
.filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
.map(key => caches.delete(key))); .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) { async function onFetch(event) {