From c2074c976821964751104bc344a452b98169a412 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:57:51 +0200 Subject: [PATCH] refactor(pwa): auf Workbox-Standard vereinfacht, refreshing-Flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Zombie-Version-Check (858d4c1) ging über das Standard-Handshake- Pattern hinaus. User will Industry-Standard: Workbox/web.dev-Pattern ohne GET_VERSION-Sonderlocke. Änderungen: - service-worker.ts: GET_VERSION-Handler entfernt. SW reagiert nur noch auf SKIP_WAITING. - pwa.svelte.ts: queryVersion + evaluateWaiting entfernt. init() zeigt Toast wieder schlicht bei registration.waiting (das ist kanonisch — bit-gleiche Bytes erzeugen keinen waiting-Slot). - controllerchange-Listener wandert nach init() mit refreshing-Flag (CRA-Idiom): verhindert Doppel-Reload, wenn User zusätzlich F5 drückt, und stellt sicher, dass der Listener in _jeder_ Session aktiv ist, nicht erst nach dem ersten reload()-Call. - pwa-store.test.ts: Tests decken jetzt waiting→Toast, no-waiting→ kein Toast, Handshake, refreshing-Flag und Sofort-Reload ab. Der Zombie-Edge-Case (Browser-Quirk mit bit-identischem waiting-SW) wird sich nach einmaligem Klick auflösen — erwarteter Trade-off gegenüber der eingesparten Komplexität. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + src/lib/client/pwa.svelte.ts | 84 +++++++--------------- src/service-worker.ts | 8 --- tests/unit/pwa-store.test.ts | 135 +++++++++++++++++++++-------------- 4 files changed, 110 insertions(+), 118 deletions(-) diff --git a/.gitignore b/.gitignore index 3156228..8393e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data/ *.log test-results/ playwright-report/ +.playwright-mcp/ diff --git a/src/lib/client/pwa.svelte.ts b/src/lib/client/pwa.svelte.ts index 2e1d86d..c2678e4 100644 --- a/src/lib/client/pwa.svelte.ts +++ b/src/lib/client/pwa.svelte.ts @@ -1,10 +1,26 @@ +// 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. class PwaStore { updateAvailable = $state(false); private registration: ServiceWorkerRegistration | null = null; private pollTimer: ReturnType | null = null; + private refreshing = false; async init(): Promise { 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 { @@ -12,13 +28,10 @@ class PwaStore { } if (!this.registration) return; - // 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); + // 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; } this.registration.addEventListener('updatefound', () => this.onUpdateFound()); @@ -34,52 +47,25 @@ class PwaStore { const installing = this.registration?.installing; if (!installing) return; installing.addEventListener('statechange', () => { - // 'installed' UND ein laufender controller = Update für bestehenden Tab. + // '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) return; - const active = this.registration?.active; - if (active && active !== installing) { - void this.evaluateWaiting(installing, active); - } else { + if (installing.state === 'installed' && navigator.serviceWorker.controller) { 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; if (!waiting) { - // Kein wartender SW — entweder war es nur eine Toast-Anzeige, oder - // der SW ist schon aktiv. In beiden Fällen reicht ein Reload. + // Kein wartender SW — reicht ein normaler Reload. + this.refreshing = true; location.reload(); return; } - // Klassisches Pattern: User-Klick → SKIP_WAITING an den wartenden - // SW → controllerchange feuert, wenn der neue SW übernimmt → dann - // reloaden wir die Seite, damit sie unter dem neuen SW läuft. - navigator.serviceWorker.addEventListener( - 'controllerchange', - () => location.reload(), - { once: true } - ); + // SKIP_WAITING an den wartenden SW → activate → controllerchange → + // der Listener in init() führt den Reload aus. waiting.postMessage({ type: 'SKIP_WAITING' }); } @@ -88,22 +74,4 @@ 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 413d55c..648acc0 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -99,14 +99,6 @@ 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 index 0f6bb49..9512fa3 100644 --- a/tests/unit/pwa-store.test.ts +++ b/tests/unit/pwa-store.test.ts @@ -4,17 +4,7 @@ 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; - } + postMessage = vi.fn(); } type Reg = { @@ -25,7 +15,10 @@ type Reg = { update: ReturnType; }; -function mountFakeSW(init: Partial): Reg { +function mountFakeSW(init: Partial): { + registration: Reg; + container: { swListeners: Record void)[]> }; +} { const registration: Reg = { active: init.active ?? null, waiting: init.waiting ?? null, @@ -33,18 +26,21 @@ function mountFakeSW(init: Partial): Reg { addEventListener: vi.fn(), update: vi.fn().mockResolvedValue(undefined) }; + const swListeners: Record void)[]> = {}; Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { ready: Promise.resolve(registration), controller: registration.active, - addEventListener: vi.fn() + addEventListener: vi.fn((type: string, fn: (e: Event) => void) => { + (swListeners[type] ??= []).push(fn); + }) } }); - return registration; + return { registration, container: { swListeners } }; } -async function flush(ms = 20): Promise { +async function flush(ms = 10): Promise { await new Promise((r) => setTimeout(r, ms)); } @@ -53,9 +49,9 @@ describe('pwa store', () => { 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'); + it('zeigt Toast, wenn beim Mount ein waiting-SW existiert', async () => { + const active = new FakeSW(); + const waiting = new FakeSW(); waiting.state = 'installed'; mountFakeSW({ active, waiting }); @@ -63,43 +59,25 @@ describe('pwa store', () => { await pwaStore.init(); await flush(); + expect(pwaStore.updateAvailable).toBe(true); + }); + + it('zeigt keinen Toast ohne waiting-SW', async () => { + const active = new FakeSW(); + mountFakeSW({ active }); + + 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'); + it('reload() postet SKIP_WAITING und reloadet erst beim controllerchange', async () => { + const active = new FakeSW(); + const waiting = new FakeSW(); 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 { container } = mountFakeSW({ active, waiting }); const reload = vi.fn(); Object.defineProperty(window, 'location', { @@ -107,7 +85,60 @@ describe('pwa store', () => { value: { ...window.location, reload } }); + const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); + await pwaStore.init(); + await flush(); + pwaStore.reload(); - expect(reload).toHaveBeenCalled(); + expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); + expect(reload).not.toHaveBeenCalled(); + + // Simuliere controllerchange, wenn der neue SW übernimmt. + container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange'))); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('refreshing-Flag verhindert doppelten Reload bei mehrfachem controllerchange', async () => { + const active = new FakeSW(); + const waiting = new FakeSW(); + waiting.state = 'installed'; + const { container } = mountFakeSW({ active, waiting }); + + const reload = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, reload } + }); + + const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); + await pwaStore.init(); + await flush(); + + const fire = () => + container.swListeners['controllerchange']?.forEach((fn) => + fn(new Event('controllerchange')) + ); + pwaStore.reload(); + fire(); + fire(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('reload() ohne waiting-SW löst location.reload() sofort aus', async () => { + const active = new FakeSW(); + mountFakeSW({ active }); + + const reload = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, reload } + }); + + const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); + await pwaStore.init(); + await flush(); + + pwaStore.reload(); + expect(reload).toHaveBeenCalledTimes(1); }); });