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:
@@ -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<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 = {
|
||||
active: init.active ?? null,
|
||||
waiting: init.waiting ?? null,
|
||||
@@ -33,18 +26,21 @@ function mountFakeSW(init: Partial<Reg>): Reg {
|
||||
addEventListener: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
const swListeners: Record<string, ((e: Event) => 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<void> {
|
||||
async function flush(ms = 10): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user