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
+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(...)`) :
//
@@ -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 <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) {
// 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();
});
})();
@@ -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) {