diff --git a/src/lib/client/pwa.svelte.ts b/src/lib/client/pwa.svelte.ts index c2678e4..8deacda 100644 --- a/src/lib/client/pwa.svelte.ts +++ b/src/lib/client/pwa.svelte.ts @@ -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 { + 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 { + 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..361eecb 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') { + // 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 }); } }); diff --git a/tests/unit/pwa-store.test.ts b/tests/unit/pwa-store.test.ts index 9512fa3..4d283fd 100644 --- a/tests/unit/pwa-store.test.ts +++ b/tests/unit/pwa-store.test.ts @@ -4,7 +4,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; class FakeSW extends EventTarget { scriptURL = '/service-worker.js'; state: 'installed' | 'activated' = 'activated'; - postMessage = vi.fn(); + 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 = null) { + super(); + this.version = version; + } } type Reg = { @@ -40,7 +50,7 @@ function mountFakeSW(init: Partial): { return { registration, container: { swListeners } }; } -async function flush(ms = 10): Promise { +async function flush(ms = 20): Promise { await new Promise((r) => setTimeout(r, ms)); } @@ -49,9 +59,37 @@ describe('pwa store', () => { vi.resetModules(); }); - it('zeigt Toast, wenn beim Mount ein waiting-SW existiert', async () => { - const active = new FakeSW(); - const waiting = new FakeSW(); + it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING', async () => { + const active = new FakeSW('1776527907402'); + const waiting = new FakeSW('1776527907402'); + 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('echtes Update (unterschiedliche Version): Toast', async () => { + const active = new FakeSW('1776526292782'); + const waiting = new FakeSW('1776527907402'); + 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('alter active-SW ohne GET_VERSION (Fallback): Toast', async () => { + const active = new FakeSW(null); + const waiting = new FakeSW('1776527907402'); waiting.state = 'installed'; mountFakeSW({ active, waiting }); @@ -62,20 +100,17 @@ describe('pwa store', () => { expect(pwaStore.updateAvailable).toBe(true); }); - it('zeigt keinen Toast ohne waiting-SW', async () => { - const active = new FakeSW(); - mountFakeSW({ active }); - + it('kein waiting-SW: kein Toast', async () => { + mountFakeSW({ active: new FakeSW('1776527907402') }); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); await flush(); - expect(pwaStore.updateAvailable).toBe(false); }); - it('reload() postet SKIP_WAITING und reloadet erst beim controllerchange', async () => { - const active = new FakeSW(); - const waiting = new FakeSW(); + it('reload() postet SKIP_WAITING, reload erst bei controllerchange', async () => { + const active = new FakeSW('v1'); + const waiting = new FakeSW('v2'); waiting.state = 'installed'; const { container } = mountFakeSW({ active, waiting }); @@ -93,14 +128,13 @@ describe('pwa store', () => { 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(); + it('refreshing-Flag unterdrückt mehrfache Reloads', async () => { + const active = new FakeSW('v1'); + const waiting = new FakeSW('v2'); waiting.state = 'installed'; const { container } = mountFakeSW({ active, waiting }); @@ -114,20 +148,16 @@ describe('pwa store', () => { await pwaStore.init(); await flush(); - const fire = () => - container.swListeners['controllerchange']?.forEach((fn) => - fn(new Event('controllerchange')) - ); pwaStore.reload(); + const fire = () => + container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange'))); fire(); fire(); expect(reload).toHaveBeenCalledTimes(1); }); - it('reload() ohne waiting-SW löst location.reload() sofort aus', async () => { - const active = new FakeSW(); - mountFakeSW({ active }); - + it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => { + mountFakeSW({ active: new FakeSW('v1') }); const reload = vi.fn(); Object.defineProperty(window, 'location', { configurable: true,