fix(pwa): Zombie-Waiting-SW erkennen und stumm aufräumen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m23s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m23s
Der vorige Fix (3d6f639) hat den Endlos-Toast "Neue Kochwas-Version
verfügbar" im Happy-Path beseitigt, aber das Grundproblem blieb:
pwaStore.init() hat blind `registration.waiting` als Update-Signal
verwendet.
Beobachtet auf der Live-PWA: Nach dem Reload-Klick existiert
registration.waiting weiter — als bit-identischer Zombie zum aktiven
SW (nur ein einziger shell-Cache `kochwas-shell-<version>`, Server-
Fetch liefert dieselbe Version-Konstante wie der active-SW). Der
Browser räumt diesen waiting-Slot nicht von selbst auf. Ergebnis:
beim nächsten init() steht `registration.waiting` wieder, Toast
kommt wieder.
Fix: SW bekommt einen GET_VERSION-MessageHandler. pwaStore fragt
active und waiting per MessageChannel nach ihrer Version. Sind sie
gleich, schickt er SKIP_WAITING silent an den Zombie und zeigt
KEINEN Toast. Nur bei echter Versions-Differenz erscheint das Update-
Angebot. Der alte onUpdateFound-Pfad geht den gleichen Weg.
Regression-Test: tests/unit/pwa-store.test.ts deckt Zombie-, Echt-
Update- und Fallback-Fall (alter SW ohne GET_VERSION) ab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,13 @@ class PwaStore {
|
||||
}
|
||||
if (!this.registration) return;
|
||||
|
||||
// Wenn beim Mount schon ein neuer SW installiert und aktiv wartet,
|
||||
// zeigen wir den Toast direkt an.
|
||||
if (this.registration.waiting) {
|
||||
this.updateAvailable = true;
|
||||
// Wenn beim Mount schon ein waiting-SW existiert, ist das nicht
|
||||
// automatisch ein echtes Update: Der Browser behält manchmal einen
|
||||
// bit-identischen Zombie im waiting-Slot (Artefakt aus einer vorigen
|
||||
// Session). Erst ein Version-Vergleich klärt, ob der neue SW wirklich
|
||||
// anderen Code ausführen würde.
|
||||
if (this.registration.waiting && this.registration.active) {
|
||||
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
|
||||
}
|
||||
|
||||
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
||||
@@ -33,12 +36,33 @@ class PwaStore {
|
||||
installing.addEventListener('statechange', () => {
|
||||
// 'installed' UND ein 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fragt active- und waiting-SW nach ihrer Version (per MessageChannel)
|
||||
// und zeigt den Toast nur, wenn sie sich unterscheiden. Bei gleicher
|
||||
// Version räumen wir den Zombie stillschweigend via SKIP_WAITING auf —
|
||||
// sonst bleibt registration.waiting bei jedem Reload belegt und der
|
||||
// Toast taucht endlos wieder auf.
|
||||
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
|
||||
const [waitingVersion, activeVersion] = await Promise.all([
|
||||
queryVersion(waiting),
|
||||
queryVersion(active)
|
||||
]);
|
||||
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
return;
|
||||
}
|
||||
this.updateAvailable = true;
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.updateAvailable = false;
|
||||
const waiting = this.registration?.waiting;
|
||||
@@ -64,4 +88,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();
|
||||
|
||||
@@ -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') {
|
||||
// pwaStore nutzt das, um active- und waiting-SW zu vergleichen: sind
|
||||
// beide bit-gleich (gleicher $service-worker-Version-Hash), dann ist
|
||||
// der waiting-SW ein Zombie aus einer vorigen Session und KEIN echtes
|
||||
// Update — sonst würde der "Neue Version"-Toast unbegrenzt wieder-
|
||||
// kehren, weil `registration.waiting` belegt bleibt.
|
||||
const port = event.ports[0] as MessagePort | undefined;
|
||||
port?.postMessage({ version });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user