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

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:
hsiegeln
2026-04-18 17:48:55 +02:00
parent 42f79f122b
commit 858d4c1622
3 changed files with 168 additions and 5 deletions

View File

@@ -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<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
function mountFakeSW(init: Partial<Reg>): 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<void> {
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();
});
});