fix(pwa): Reload-Loop beim Zombie-Cleanup beseitigt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s

Der Commit 1bec054 hatte einen globalen controllerchange-Listener in
init(), der bei jedem Event location.reload() auslöste. In Kombination
mit der Zombie-Aufräumung (silent SKIP_WAITING) ergab das einen
Endlos-Loop: Seite lädt → Zombie erkannt → SKIP_WAITING → controller-
change → Reload → neue Seite mit frischem Zombie → usw.

Fix: Der controllerchange-Listener wird nur noch scoped aus reload()
heraus gesetzt ({ once: true }) — also genau dann, wenn der User auf
„Neu laden" geklickt hat und einen Reload tatsächlich will. Beim
silent Zombie-Cleanup gibt es keinen Listener, die Seite läuft
einfach nahtlos unter dem neuen (funktional identischen) SW weiter.

Regression-Test sichert ab, dass fireControllerChange() nach silent
SKIP_WAITING location.reload() NICHT aufruft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 18:12:19 +02:00
parent 1bec054ec6
commit 854af2fc34
2 changed files with 62 additions and 57 deletions

View File

@@ -9,21 +9,19 @@
// als „neues Update" interpretieren und den Toast bei jedem Reload
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
// beiden SWs, vergleichen und räumen identische Bytes still auf.
//
// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick
// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der
// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue
// Seite wieder mit frischem Zombie ausstattet.
class PwaStore {
updateAvailable = $state(false);
private registration: ServiceWorkerRegistration | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private refreshing = false;
async init(): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (this.refreshing) return;
this.refreshing = true;
location.reload();
});
try {
this.registration = await navigator.serviceWorker.ready;
} catch {
@@ -64,13 +62,12 @@ class PwaStore {
queryVersion(active)
]);
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
// Bit-identischer Zombie — ohne User-Toast aufräumen. Der neue
// SW wird zur Active, controllerchange feuert, init()-Listener
// triggert einen einzigen Reload.
// Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite
// läuft nahtlos unter dem neuen SW weiter (funktional identisch).
waiting.postMessage({ type: 'SKIP_WAITING' });
return;
}
// Versions-Unterschied oder unbekannt: User entscheidet.
// Versions-Unterschied oder unbekannt: User entscheidet via Toast.
this.updateAvailable = true;
}
@@ -78,11 +75,17 @@ class PwaStore {
this.updateAvailable = false;
const waiting = this.registration?.waiting;
if (!waiting) {
this.refreshing = true;
// Kein wartender SW — reicht ein normaler Reload.
location.reload();
return;
}
// SKIP_WAITING → activate → controllerchange → init()-Listener reloadet.
// Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange
// feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig.
navigator.serviceWorker.addEventListener(
'controllerchange',
() => location.reload(),
{ once: true }
);
waiting.postMessage({ type: 'SKIP_WAITING' });
}