From 858d4c162274f3deaa6ac8f1054675a55218033e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:48:55 +0200 Subject: [PATCH] =?UTF-8?q?fix(pwa):=20Zombie-Waiting-SW=20erkennen=20und?= =?UTF-8?q?=20stumm=20aufr=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-`, 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) --- src/lib/client/pwa.svelte.ts | 52 ++++++++++++++-- src/service-worker.ts | 8 +++ tests/unit/pwa-store.test.ts | 113 +++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 tests/unit/pwa-store.test.ts diff --git a/src/lib/client/pwa.svelte.ts b/src/lib/client/pwa.svelte.ts index e84d991..2e1d86d 100644 --- a/src/lib/client/pwa.svelte.ts +++ b/src/lib/client/pwa.svelte.ts @@ -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 { + 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 { + 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(); diff --git a/src/service-worker.ts b/src/service-worker.ts index 648acc0..413d55c 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -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 }); } }); diff --git a/tests/unit/pwa-store.test.ts b/tests/unit/pwa-store.test.ts new file mode 100644 index 0000000..0f6bb49 --- /dev/null +++ b/tests/unit/pwa-store.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +class FakeSW extends EventTarget { + scriptURL = '/service-worker.js'; + state: 'installed' | 'activated' = 'activated'; + version: string | null; + postMessage = vi.fn((msg: unknown, transfer?: Transferable[]) => { + if ((msg as { type?: string } | null)?.type === 'GET_VERSION') { + const port = transfer?.[0] as MessagePort | undefined; + if (port && this.version !== null) port.postMessage({ version: this.version }); + } + }); + constructor(version: string | null) { + super(); + this.version = version; + } +} + +type Reg = { + active: FakeSW | null; + waiting: FakeSW | null; + installing: FakeSW | null; + addEventListener: ReturnType; + update: ReturnType; +}; + +function mountFakeSW(init: Partial): Reg { + const registration: Reg = { + active: init.active ?? null, + waiting: init.waiting ?? null, + installing: init.installing ?? null, + addEventListener: vi.fn(), + update: vi.fn().mockResolvedValue(undefined) + }; + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + ready: Promise.resolve(registration), + controller: registration.active, + addEventListener: vi.fn() + } + }); + return registration; +} + +async function flush(ms = 20): Promise { + await new Promise((r) => setTimeout(r, ms)); +} + +describe('pwa store', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('bleibt still, wenn waiting- und active-SW dieselbe Version melden (Zombie)', async () => { + const active = new FakeSW('1776526292782'); + const waiting = new FakeSW('1776526292782'); + waiting.state = 'installed'; + mountFakeSW({ active, waiting }); + + const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); + await pwaStore.init(); + await flush(); + + expect(pwaStore.updateAvailable).toBe(false); + expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); + }); + + it('zeigt Toast, wenn sich die Versionen unterscheiden', async () => { + const active = new FakeSW('1776526292782'); + const waiting = new FakeSW('1776999999999'); + waiting.state = 'installed'; + mountFakeSW({ active, waiting }); + + const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); + await pwaStore.init(); + await flush(); + + expect(pwaStore.updateAvailable).toBe(true); + expect(waiting.postMessage).not.toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); + }); + + it('zeigt Toast, wenn der alte active-SW keine Version liefert (Fallback)', async () => { + const active = new FakeSW(null); + const waiting = new FakeSW('1776999999999'); + waiting.state = 'installed'; + mountFakeSW({ active, waiting }); + + const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); + await pwaStore.init(); + await flush(); + + expect(pwaStore.updateAvailable).toBe(true); + }); + + it('reload() ohne waiting-SW macht nur location.reload()', async () => { + const active = new FakeSW('1776526292782'); + mountFakeSW({ active }); + const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); + await pwaStore.init(); + await flush(); + + const reload = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, reload } + }); + + pwaStore.reload(); + expect(reload).toHaveBeenCalled(); + }); +});