fix(pwa): Zombie-waiting-SW via GET_VERSION erkennen (Live-Bug)
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Das reine Workbox-Handshake-Pattern ausc2074c9reicht für dieses Deploy nicht. Live-Analyse mit Playwright ergibt reproduzierbar nach dem Reload-Klick: - active-SW: Version 1776527907402 - waiting-SW: Version 1776527907402 (bit-identisch!) - Nur ein einziger shell-Cache - Server-Response: gleiche Version → Toast kommt bei jedem Reload erneut. Vermutung: Race zwischen Chromium-SW-Update-Check (der parallel zum SKIP_WAITING läuft) und activate. Der Browser hält den zweiten Installation-Versuch mit identischen Bytes im waiting-Slot. Fix: SW bekommt GET_VERSION-Handler, Client fragt via MessageChannel active und waiting nach Version. Bei Gleichheit räumt er den Zombie stumm auf (SKIP_WAITING ohne Toast), bei Versions-Unterschied zeigt er den Toast. Der refreshing-Flag-Reload-Guard ausc2074c9bleibt erhalten. Industry-Standard-Pattern bleibt die Basis; GET_VERSION ist ein defensiver Zusatz für einen reproduzierbaren Browser-Edge-Case, den Workbox nicht abfängt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Reg>): {
|
||||
return { registration, container: { swListeners } };
|
||||
}
|
||||
|
||||
async function flush(ms = 10): Promise<void> {
|
||||
async function flush(ms = 20): Promise<void> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user