fix(pwa): Zombie-waiting-SW via GET_VERSION erkennen (Live-Bug)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s

Das reine Workbox-Handshake-Pattern aus c2074c9 reicht für dieses
Deploy nicht. Live-Analyse mit Playwright ergibt reproduzierbar nach
dem Reload-Klick:
- active-SW: Version 1776527907402
- waiting-SW: Version 1776527907402 (bit-identisch!)
- Nur ein einziger shell-Cache
- Server-Response: gleiche Version
→ Toast kommt bei jedem Reload erneut.

Vermutung: Race zwischen Chromium-SW-Update-Check (der parallel
zum SKIP_WAITING läuft) und activate. Der Browser hält den zweiten
Installation-Versuch mit identischen Bytes im waiting-Slot.

Fix: SW bekommt GET_VERSION-Handler, Client fragt via MessageChannel
active und waiting nach Version. Bei Gleichheit räumt er den Zombie
stumm auf (SKIP_WAITING ohne Toast), bei Versions-Unterschied
zeigt er den Toast. Der refreshing-Flag-Reload-Guard aus c2074c9
bleibt erhalten.

Industry-Standard-Pattern bleibt die Basis; GET_VERSION ist ein
defensiver Zusatz für einen reproduzierbaren Browser-Edge-Case,
den Workbox nicht abfängt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 18:06:36 +02:00
parent c2074c9768
commit 1bec054ec6
3 changed files with 116 additions and 43 deletions

View File

@@ -1,11 +1,14 @@
// Standard Service-Worker-Update-Pattern (Workbox-Style, web.dev „The
// Service Worker Lifecycle"): Der SW ruft im Install-Handler NICHT
// skipWaiting() auf. Bei einem Update landet der neue SW im waiting-
// Status, wir zeigen dem User einen Toast. Klickt er „Neu laden",
// posten wir SKIP_WAITING an den wartenden SW, warten auf den
// controllerchange und reloaden einmalig — das refreshing-Flag
// verhindert den klassischen Doppel-Reload, wenn der User zusätzlich
// manuell F5 drückt.
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
// skipWaiting im install-Handler, User bestätigt via Toast) mit
// zusätzlichem Zombie-Schutz.
//
// Warum der Zombie-Schutz nötig ist: Chromium hält auf diesem Deploy
// reproduzierbar nach einem SKIP_WAITING+Reload einen bit-identischen
// waiting-SW im Registration-Slot — wohl durch einen Race zwischen
// SW-Update-Check und activate. Der reine Workbox-Standard würde den
// 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.
class PwaStore {
updateAvailable = $state(false);
private registration: ServiceWorkerRegistration | null = null;
@@ -28,10 +31,8 @@ class PwaStore {
}
if (!this.registration) return;
// Waiting-SW beim Mount = echtes, vom Browser als neu erkanntes
// Update (gleiche Bytes hätten keinen waiting-Slot erzeugt).
if (this.registration.waiting) {
this.updateAvailable = true;
if (this.registration.waiting && this.registration.active) {
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
}
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
@@ -47,25 +48,41 @@ class PwaStore {
const installing = this.registration?.installing;
if (!installing) return;
installing.addEventListener('statechange', () => {
// 'installed' UND laufender controller = Update für bestehenden Tab.
// (Ohne controller wäre das die erste Installation, kein Update.)
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
const active = this.registration?.active;
if (active && active !== installing) {
void this.evaluateWaiting(installing, active);
} else {
this.updateAvailable = true;
}
});
}
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
const [waitingVersion, activeVersion] = await Promise.all([
queryVersion(waiting),
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.
waiting.postMessage({ type: 'SKIP_WAITING' });
return;
}
// Versions-Unterschied oder unbekannt: User entscheidet.
this.updateAvailable = true;
}
reload(): void {
this.updateAvailable = false;
const waiting = this.registration?.waiting;
if (!waiting) {
// Kein wartender SW — reicht ein normaler Reload.
this.refreshing = true;
location.reload();
return;
}
// SKIP_WAITING an den wartenden SW → activate → controllerchange →
// der Listener in init() führt den Reload aus.
// SKIP_WAITING → activate → controllerchange → init()-Listener reloadet.
waiting.postMessage({ type: 'SKIP_WAITING' });
}
@@ -74,4 +91,22 @@ class PwaStore {
}
}
function queryVersion(sw: ServiceWorker): Promise<string | null> {
return new Promise((resolve) => {
const channel = new MessageChannel();
const timer = setTimeout(() => resolve(null), 1500);
channel.port1.onmessage = (e) => {
clearTimeout(timer);
const v = (e.data as { version?: unknown } | null)?.version;
resolve(typeof v === 'string' ? v : null);
};
try {
sw.postMessage({ type: 'GET_VERSION' }, [channel.port2]);
} catch {
clearTimeout(timer);
resolve(null);
}
});
}
export const pwaStore = new PwaStore();

View File

@@ -99,6 +99,14 @@ self.addEventListener('message', (event) => {
} else if (data.type === 'SKIP_WAITING') {
// Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt.
void self.skipWaiting();
} else if (data.type === 'GET_VERSION') {
// Zombie-Schutz: Chromium hält nach einem SKIP_WAITING-Zyklus
// mitunter einen bit-identischen waiting-SW im Registration-Slot
// (Race zwischen SW-Update-Check während activate). Ohne diesen
// Version-Handshake zeigt init() den „Neue Version"-Toast bei jedem
// Reload erneut, obwohl es nichts zu aktualisieren gibt.
const port = event.ports[0] as MessagePort | undefined;
port?.postMessage({ version });
}
});