diff --git a/src/lib/client/pwa.svelte.ts b/src/lib/client/pwa.svelte.ts index 8deacda..a5eb10e 100644 --- a/src/lib/client/pwa.svelte.ts +++ b/src/lib/client/pwa.svelte.ts @@ -9,21 +9,19 @@ // 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. +// +// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick +// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der +// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue +// Seite wieder mit frischem Zombie ausstattet. 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 { @@ -64,13 +62,12 @@ class PwaStore { 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. + // Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite + // läuft nahtlos unter dem neuen SW weiter (funktional identisch). waiting.postMessage({ type: 'SKIP_WAITING' }); return; } - // Versions-Unterschied oder unbekannt: User entscheidet. + // Versions-Unterschied oder unbekannt: User entscheidet via Toast. this.updateAvailable = true; } @@ -78,11 +75,17 @@ class PwaStore { this.updateAvailable = false; const waiting = this.registration?.waiting; if (!waiting) { - this.refreshing = true; + // Kein wartender SW — reicht ein normaler Reload. location.reload(); return; } - // SKIP_WAITING → activate → controllerchange → init()-Listener reloadet. + // Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange + // feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig. + navigator.serviceWorker.addEventListener( + 'controllerchange', + () => location.reload(), + { once: true } + ); waiting.postMessage({ type: 'SKIP_WAITING' }); } diff --git a/tests/unit/pwa-store.test.ts b/tests/unit/pwa-store.test.ts index 4d283fd..d1a391e 100644 --- a/tests/unit/pwa-store.test.ts +++ b/tests/unit/pwa-store.test.ts @@ -27,7 +27,7 @@ type Reg = { function mountFakeSW(init: Partial): { registration: Reg; - container: { swListeners: Record void)[]> }; + fireControllerChange: () => void; } { const registration: Reg = { active: init.active ?? null, @@ -36,34 +36,59 @@ function mountFakeSW(init: Partial): { addEventListener: vi.fn(), update: vi.fn().mockResolvedValue(undefined) }; - const swListeners: Record void)[]> = {}; + type Entry = { fn: (e: Event) => void; once: boolean }; + const listeners: Entry[] = []; Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { ready: Promise.resolve(registration), controller: registration.active, - addEventListener: vi.fn((type: string, fn: (e: Event) => void) => { - (swListeners[type] ??= []).push(fn); - }) + addEventListener: vi.fn( + (type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions | boolean) => { + if (type !== 'controllerchange') return; + const once = typeof opts === 'object' ? !!opts.once : false; + listeners.push({ fn, once }); + } + ) } }); - return { registration, container: { swListeners } }; + const fireControllerChange = () => { + const snap = [...listeners]; + for (const e of snap) { + e.fn(new Event('controllerchange')); + if (e.once) { + const i = listeners.indexOf(e); + if (i >= 0) listeners.splice(i, 1); + } + } + }; + return { registration, fireControllerChange }; } async function flush(ms = 20): Promise { await new Promise((r) => setTimeout(r, ms)); } +function stubLocationReload(): ReturnType { + const reload = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, reload } + }); + return reload; +} + describe('pwa store', () => { beforeEach(() => { vi.resetModules(); }); - it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING', async () => { + it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING, KEIN Reload', async () => { const active = new FakeSW('1776527907402'); const waiting = new FakeSW('1776527907402'); waiting.state = 'installed'; - mountFakeSW({ active, waiting }); + const { fireControllerChange } = mountFakeSW({ active, waiting }); + const reload = stubLocationReload(); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); @@ -71,6 +96,12 @@ describe('pwa store', () => { expect(pwaStore.updateAvailable).toBe(false); expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); + // Kritisch: selbst wenn der Browser nach dem silent SKIP_WAITING + // controllerchange feuert, darf kein Auto-Reload passieren — sonst + // Endlos-Loop, weil der nächste Seitenaufruf erneut einen Zombie + // bekommt. + fireControllerChange(); + expect(reload).not.toHaveBeenCalled(); }); it('echtes Update (unterschiedliche Version): Toast', async () => { @@ -108,17 +139,12 @@ describe('pwa store', () => { expect(pwaStore.updateAvailable).toBe(false); }); - it('reload() postet SKIP_WAITING, reload erst bei controllerchange', async () => { + it('reload() postet SKIP_WAITING, reload einmalig bei controllerchange', async () => { const active = new FakeSW('v1'); const waiting = new FakeSW('v2'); waiting.state = 'installed'; - const { container } = mountFakeSW({ active, waiting }); - - const reload = vi.fn(); - Object.defineProperty(window, 'location', { - configurable: true, - value: { ...window.location, reload } - }); + const { fireControllerChange } = mountFakeSW({ active, waiting }); + const reload = stubLocationReload(); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init(); @@ -128,41 +154,17 @@ describe('pwa store', () => { expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); expect(reload).not.toHaveBeenCalled(); - container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange'))); + fireControllerChange(); expect(reload).toHaveBeenCalledTimes(1); - }); - 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 }); - - 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(); - const fire = () => - container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange'))); - fire(); - fire(); + // Nochmal controllerchange → wegen { once: true } kein zweiter Reload. + fireControllerChange(); expect(reload).toHaveBeenCalledTimes(1); }); 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, - value: { ...window.location, reload } - }); + const reload = stubLocationReload(); const { pwaStore } = await import('../../src/lib/client/pwa.svelte'); await pwaStore.init();