refactor(pwa): auf Workbox-Standard vereinfacht, refreshing-Flag
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
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) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ data/
|
|||||||
*.log
|
*.log
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -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 {
|
class PwaStore {
|
||||||
updateAvailable = $state(false);
|
updateAvailable = $state(false);
|
||||||
private registration: ServiceWorkerRegistration | null = null;
|
private registration: ServiceWorkerRegistration | null = null;
|
||||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private refreshing = false;
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
if (this.refreshing) return;
|
||||||
|
this.refreshing = true;
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.registration = await navigator.serviceWorker.ready;
|
this.registration = await navigator.serviceWorker.ready;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -12,13 +28,10 @@ class PwaStore {
|
|||||||
}
|
}
|
||||||
if (!this.registration) return;
|
if (!this.registration) return;
|
||||||
|
|
||||||
// Wenn beim Mount schon ein waiting-SW existiert, ist das nicht
|
// Waiting-SW beim Mount = echtes, vom Browser als neu erkanntes
|
||||||
// automatisch ein echtes Update: Der Browser behält manchmal einen
|
// Update (gleiche Bytes hätten keinen waiting-Slot erzeugt).
|
||||||
// bit-identischen Zombie im waiting-Slot (Artefakt aus einer vorigen
|
if (this.registration.waiting) {
|
||||||
// Session). Erst ein Version-Vergleich klärt, ob der neue SW wirklich
|
this.updateAvailable = true;
|
||||||
// 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());
|
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
||||||
@@ -34,52 +47,25 @@ class PwaStore {
|
|||||||
const installing = this.registration?.installing;
|
const installing = this.registration?.installing;
|
||||||
if (!installing) return;
|
if (!installing) return;
|
||||||
installing.addEventListener('statechange', () => {
|
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.)
|
// (Ohne controller wäre das die erste Installation, kein Update.)
|
||||||
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
|
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
const active = this.registration?.active;
|
|
||||||
if (active && active !== installing) {
|
|
||||||
void this.evaluateWaiting(installing, active);
|
|
||||||
} else {
|
|
||||||
this.updateAvailable = true;
|
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<void> {
|
|
||||||
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 {
|
reload(): void {
|
||||||
this.updateAvailable = false;
|
this.updateAvailable = false;
|
||||||
const waiting = this.registration?.waiting;
|
const waiting = this.registration?.waiting;
|
||||||
if (!waiting) {
|
if (!waiting) {
|
||||||
// Kein wartender SW — entweder war es nur eine Toast-Anzeige, oder
|
// Kein wartender SW — reicht ein normaler Reload.
|
||||||
// der SW ist schon aktiv. In beiden Fällen reicht ein Reload.
|
this.refreshing = true;
|
||||||
location.reload();
|
location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Klassisches Pattern: User-Klick → SKIP_WAITING an den wartenden
|
// SKIP_WAITING an den wartenden SW → activate → controllerchange →
|
||||||
// SW → controllerchange feuert, wenn der neue SW übernimmt → dann
|
// der Listener in init() führt den Reload aus.
|
||||||
// reloaden wir die Seite, damit sie unter dem neuen SW läuft.
|
|
||||||
navigator.serviceWorker.addEventListener(
|
|
||||||
'controllerchange',
|
|
||||||
() => location.reload(),
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,22 +74,4 @@ class PwaStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryVersion(sw: ServiceWorker): Promise<string | null> {
|
|
||||||
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();
|
export const pwaStore = new PwaStore();
|
||||||
|
|||||||
@@ -99,14 +99,6 @@ self.addEventListener('message', (event) => {
|
|||||||
} else if (data.type === 'SKIP_WAITING') {
|
} else if (data.type === 'SKIP_WAITING') {
|
||||||
// Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt.
|
// Wird vom pwaStore nach User-Klick auf "Neu laden" geschickt.
|
||||||
void self.skipWaiting();
|
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 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
class FakeSW extends EventTarget {
|
class FakeSW extends EventTarget {
|
||||||
scriptURL = '/service-worker.js';
|
scriptURL = '/service-worker.js';
|
||||||
state: 'installed' | 'activated' = 'activated';
|
state: 'installed' | 'activated' = 'activated';
|
||||||
version: string | null;
|
postMessage = vi.fn();
|
||||||
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 = {
|
type Reg = {
|
||||||
@@ -25,7 +15,10 @@ type Reg = {
|
|||||||
update: ReturnType<typeof vi.fn>;
|
update: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function mountFakeSW(init: Partial<Reg>): Reg {
|
function mountFakeSW(init: Partial<Reg>): {
|
||||||
|
registration: Reg;
|
||||||
|
container: { swListeners: Record<string, ((e: Event) => void)[]> };
|
||||||
|
} {
|
||||||
const registration: Reg = {
|
const registration: Reg = {
|
||||||
active: init.active ?? null,
|
active: init.active ?? null,
|
||||||
waiting: init.waiting ?? null,
|
waiting: init.waiting ?? null,
|
||||||
@@ -33,18 +26,21 @@ function mountFakeSW(init: Partial<Reg>): Reg {
|
|||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
update: vi.fn().mockResolvedValue(undefined)
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
};
|
};
|
||||||
|
const swListeners: Record<string, ((e: Event) => void)[]> = {};
|
||||||
Object.defineProperty(navigator, 'serviceWorker', {
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
ready: Promise.resolve(registration),
|
ready: Promise.resolve(registration),
|
||||||
controller: registration.active,
|
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<void> {
|
async function flush(ms = 10): Promise<void> {
|
||||||
await new Promise((r) => setTimeout(r, ms));
|
await new Promise((r) => setTimeout(r, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +49,9 @@ describe('pwa store', () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bleibt still, wenn waiting- und active-SW dieselbe Version melden (Zombie)', async () => {
|
it('zeigt Toast, wenn beim Mount ein waiting-SW existiert', async () => {
|
||||||
const active = new FakeSW('1776526292782');
|
const active = new FakeSW();
|
||||||
const waiting = new FakeSW('1776526292782');
|
const waiting = new FakeSW();
|
||||||
waiting.state = 'installed';
|
waiting.state = 'installed';
|
||||||
mountFakeSW({ active, waiting });
|
mountFakeSW({ active, waiting });
|
||||||
|
|
||||||
@@ -63,43 +59,25 @@ describe('pwa store', () => {
|
|||||||
await pwaStore.init();
|
await pwaStore.init();
|
||||||
await flush();
|
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(pwaStore.updateAvailable).toBe(false);
|
||||||
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt Toast, wenn sich die Versionen unterscheiden', async () => {
|
it('reload() postet SKIP_WAITING und reloadet erst beim controllerchange', async () => {
|
||||||
const active = new FakeSW('1776526292782');
|
const active = new FakeSW();
|
||||||
const waiting = new FakeSW('1776999999999');
|
const waiting = new FakeSW();
|
||||||
waiting.state = 'installed';
|
waiting.state = 'installed';
|
||||||
mountFakeSW({ active, waiting });
|
const { container } = 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();
|
const reload = vi.fn();
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
@@ -107,7 +85,60 @@ describe('pwa store', () => {
|
|||||||
value: { ...window.location, reload }
|
value: { ...window.location, reload }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||||
|
await pwaStore.init();
|
||||||
|
await flush();
|
||||||
|
|
||||||
pwaStore.reload();
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user