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:
@@ -1,11 +1,14 @@
|
|||||||
// Standard Service-Worker-Update-Pattern (Workbox-Style, web.dev „The
|
// Service-Worker-Update-Pattern: Workbox-Style Handshake (kein
|
||||||
// Service Worker Lifecycle"): Der SW ruft im Install-Handler NICHT
|
// skipWaiting im install-Handler, User bestätigt via Toast) mit
|
||||||
// skipWaiting() auf. Bei einem Update landet der neue SW im waiting-
|
// zusätzlichem Zombie-Schutz.
|
||||||
// Status, wir zeigen dem User einen Toast. Klickt er „Neu laden",
|
//
|
||||||
// posten wir SKIP_WAITING an den wartenden SW, warten auf den
|
// Warum der Zombie-Schutz nötig ist: Chromium hält auf diesem Deploy
|
||||||
// controllerchange und reloaden einmalig — das refreshing-Flag
|
// reproduzierbar nach einem SKIP_WAITING+Reload einen bit-identischen
|
||||||
// verhindert den klassischen Doppel-Reload, wenn der User zusätzlich
|
// waiting-SW im Registration-Slot — wohl durch einen Race zwischen
|
||||||
// manuell F5 drückt.
|
// SW-Update-Check und activate. Der reine Workbox-Standard würde den
|
||||||
|
// 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.
|
||||||
class PwaStore {
|
class PwaStore {
|
||||||
updateAvailable = $state(false);
|
updateAvailable = $state(false);
|
||||||
private registration: ServiceWorkerRegistration | null = null;
|
private registration: ServiceWorkerRegistration | null = null;
|
||||||
@@ -28,10 +31,8 @@ class PwaStore {
|
|||||||
}
|
}
|
||||||
if (!this.registration) return;
|
if (!this.registration) return;
|
||||||
|
|
||||||
// Waiting-SW beim Mount = echtes, vom Browser als neu erkanntes
|
if (this.registration.waiting && this.registration.active) {
|
||||||
// Update (gleiche Bytes hätten keinen waiting-Slot erzeugt).
|
await this.evaluateWaiting(this.registration.waiting, this.registration.active);
|
||||||
if (this.registration.waiting) {
|
|
||||||
this.updateAvailable = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
this.registration.addEventListener('updatefound', () => this.onUpdateFound());
|
||||||
@@ -47,25 +48,41 @@ 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 laufender controller = Update für bestehenden Tab.
|
if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return;
|
||||||
// (Ohne controller wäre das die erste Installation, kein Update.)
|
const active = this.registration?.active;
|
||||||
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
|
if (active && active !== installing) {
|
||||||
|
void this.evaluateWaiting(installing, active);
|
||||||
|
} else {
|
||||||
this.updateAvailable = true;
|
this.updateAvailable = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async evaluateWaiting(waiting: ServiceWorker, active: ServiceWorker): Promise<void> {
|
||||||
|
const [waitingVersion, activeVersion] = await Promise.all([
|
||||||
|
queryVersion(waiting),
|
||||||
|
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.
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Versions-Unterschied oder unbekannt: User entscheidet.
|
||||||
|
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 — reicht ein normaler Reload.
|
|
||||||
this.refreshing = true;
|
this.refreshing = true;
|
||||||
location.reload();
|
location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// SKIP_WAITING an den wartenden SW → activate → controllerchange →
|
// SKIP_WAITING → activate → controllerchange → init()-Listener reloadet.
|
||||||
// der Listener in init() führt den Reload aus.
|
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,4 +91,22 @@ 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,6 +99,14 @@ 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') {
|
||||||
|
// Zombie-Schutz: Chromium hält nach einem SKIP_WAITING-Zyklus
|
||||||
|
// mitunter einen bit-identischen waiting-SW im Registration-Slot
|
||||||
|
// (Race zwischen SW-Update-Check während activate). Ohne diesen
|
||||||
|
// Version-Handshake zeigt init() den „Neue Version"-Toast bei jedem
|
||||||
|
// Reload erneut, obwohl es nichts zu aktualisieren gibt.
|
||||||
|
const port = event.ports[0] as MessagePort | undefined;
|
||||||
|
port?.postMessage({ version });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,17 @@ 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';
|
||||||
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 = {
|
type Reg = {
|
||||||
@@ -40,7 +50,7 @@ function mountFakeSW(init: Partial<Reg>): {
|
|||||||
return { registration, container: { swListeners } };
|
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));
|
await new Promise((r) => setTimeout(r, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +59,37 @@ describe('pwa store', () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt Toast, wenn beim Mount ein waiting-SW existiert', async () => {
|
it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING', async () => {
|
||||||
const active = new FakeSW();
|
const active = new FakeSW('1776527907402');
|
||||||
const waiting = new FakeSW();
|
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';
|
waiting.state = 'installed';
|
||||||
mountFakeSW({ active, waiting });
|
mountFakeSW({ active, waiting });
|
||||||
|
|
||||||
@@ -62,20 +100,17 @@ describe('pwa store', () => {
|
|||||||
expect(pwaStore.updateAvailable).toBe(true);
|
expect(pwaStore.updateAvailable).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt keinen Toast ohne waiting-SW', async () => {
|
it('kein waiting-SW: kein Toast', async () => {
|
||||||
const active = new FakeSW();
|
mountFakeSW({ active: new FakeSW('1776527907402') });
|
||||||
mountFakeSW({ active });
|
|
||||||
|
|
||||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||||
await pwaStore.init();
|
await pwaStore.init();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(pwaStore.updateAvailable).toBe(false);
|
expect(pwaStore.updateAvailable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reload() postet SKIP_WAITING und reloadet erst beim controllerchange', async () => {
|
it('reload() postet SKIP_WAITING, reload erst bei controllerchange', async () => {
|
||||||
const active = new FakeSW();
|
const active = new FakeSW('v1');
|
||||||
const waiting = new FakeSW();
|
const waiting = new FakeSW('v2');
|
||||||
waiting.state = 'installed';
|
waiting.state = 'installed';
|
||||||
const { container } = mountFakeSW({ active, waiting });
|
const { container } = mountFakeSW({ active, waiting });
|
||||||
|
|
||||||
@@ -93,14 +128,13 @@ describe('pwa store', () => {
|
|||||||
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
||||||
expect(reload).not.toHaveBeenCalled();
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Simuliere controllerchange, wenn der neue SW übernimmt.
|
|
||||||
container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange')));
|
container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange')));
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshing-Flag verhindert doppelten Reload bei mehrfachem controllerchange', async () => {
|
it('refreshing-Flag unterdrückt mehrfache Reloads', async () => {
|
||||||
const active = new FakeSW();
|
const active = new FakeSW('v1');
|
||||||
const waiting = new FakeSW();
|
const waiting = new FakeSW('v2');
|
||||||
waiting.state = 'installed';
|
waiting.state = 'installed';
|
||||||
const { container } = mountFakeSW({ active, waiting });
|
const { container } = mountFakeSW({ active, waiting });
|
||||||
|
|
||||||
@@ -114,20 +148,16 @@ describe('pwa store', () => {
|
|||||||
await pwaStore.init();
|
await pwaStore.init();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
const fire = () =>
|
|
||||||
container.swListeners['controllerchange']?.forEach((fn) =>
|
|
||||||
fn(new Event('controllerchange'))
|
|
||||||
);
|
|
||||||
pwaStore.reload();
|
pwaStore.reload();
|
||||||
|
const fire = () =>
|
||||||
|
container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange')));
|
||||||
fire();
|
fire();
|
||||||
fire();
|
fire();
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reload() ohne waiting-SW löst location.reload() sofort aus', async () => {
|
it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => {
|
||||||
const active = new FakeSW();
|
mountFakeSW({ active: new FakeSW('v1') });
|
||||||
mountFakeSW({ active });
|
|
||||||
|
|
||||||
const reload = vi.fn();
|
const reload = vi.fn();
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user