fix(pwa): Reload-Loop beim Zombie-Cleanup beseitigt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 29s
Der Commit 1bec054 hatte einen globalen controllerchange-Listener in
init(), der bei jedem Event location.reload() auslöste. In Kombination
mit der Zombie-Aufräumung (silent SKIP_WAITING) ergab das einen
Endlos-Loop: Seite lädt → Zombie erkannt → SKIP_WAITING → controller-
change → Reload → neue Seite mit frischem Zombie → usw.
Fix: Der controllerchange-Listener wird nur noch scoped aus reload()
heraus gesetzt ({ once: true }) — also genau dann, wenn der User auf
„Neu laden" geklickt hat und einen Reload tatsächlich will. Beim
silent Zombie-Cleanup gibt es keinen Listener, die Seite läuft
einfach nahtlos unter dem neuen (funktional identischen) SW weiter.
Regression-Test sichert ab, dass fireControllerChange() nach silent
SKIP_WAITING location.reload() NICHT aufruft.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,21 +9,19 @@
|
|||||||
// als „neues Update" interpretieren und den Toast bei jedem Reload
|
// als „neues Update" interpretieren und den Toast bei jedem Reload
|
||||||
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
|
// erneut zeigen. Wir fragen darum per MessageChannel GET_VERSION an
|
||||||
// beiden SWs, vergleichen und räumen identische Bytes still auf.
|
// beiden SWs, vergleichen und räumen identische Bytes still auf.
|
||||||
|
//
|
||||||
|
// Kritisch: Der Reload beim controllerchange darf NUR durch User-Klick
|
||||||
|
// passieren, nicht automatisch beim silent Cleanup — sonst ergibt der
|
||||||
|
// Zombie-Refresh einen Endlos-Reload-Loop, weil der Browser jede neue
|
||||||
|
// Seite wieder mit frischem Zombie ausstattet.
|
||||||
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 {
|
||||||
@@ -64,13 +62,12 @@ class PwaStore {
|
|||||||
queryVersion(active)
|
queryVersion(active)
|
||||||
]);
|
]);
|
||||||
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
|
if (waitingVersion && activeVersion && waitingVersion === activeVersion) {
|
||||||
// Bit-identischer Zombie — ohne User-Toast aufräumen. Der neue
|
// Bit-identischer Zombie: silent aufräumen, KEIN reload — die Seite
|
||||||
// SW wird zur Active, controllerchange feuert, init()-Listener
|
// läuft nahtlos unter dem neuen SW weiter (funktional identisch).
|
||||||
// triggert einen einzigen Reload.
|
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Versions-Unterschied oder unbekannt: User entscheidet.
|
// Versions-Unterschied oder unbekannt: User entscheidet via Toast.
|
||||||
this.updateAvailable = true;
|
this.updateAvailable = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +75,17 @@ class PwaStore {
|
|||||||
this.updateAvailable = false;
|
this.updateAvailable = false;
|
||||||
const waiting = this.registration?.waiting;
|
const waiting = this.registration?.waiting;
|
||||||
if (!waiting) {
|
if (!waiting) {
|
||||||
this.refreshing = true;
|
// Kein wartender SW — reicht ein normaler Reload.
|
||||||
location.reload();
|
location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// SKIP_WAITING → activate → controllerchange → init()-Listener reloadet.
|
// Klassisches Pattern: User-Klick → SKIP_WAITING → controllerchange
|
||||||
|
// feuert, wenn der neue SW übernimmt → dann reloaden wir einmalig.
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
() => location.reload(),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type Reg = {
|
|||||||
|
|
||||||
function mountFakeSW(init: Partial<Reg>): {
|
function mountFakeSW(init: Partial<Reg>): {
|
||||||
registration: Reg;
|
registration: Reg;
|
||||||
container: { swListeners: Record<string, ((e: Event) => void)[]> };
|
fireControllerChange: () => void;
|
||||||
} {
|
} {
|
||||||
const registration: Reg = {
|
const registration: Reg = {
|
||||||
active: init.active ?? null,
|
active: init.active ?? null,
|
||||||
@@ -36,34 +36,59 @@ function mountFakeSW(init: Partial<Reg>): {
|
|||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
update: vi.fn().mockResolvedValue(undefined)
|
update: vi.fn().mockResolvedValue(undefined)
|
||||||
};
|
};
|
||||||
const swListeners: Record<string, ((e: Event) => void)[]> = {};
|
type Entry = { fn: (e: Event) => void; once: boolean };
|
||||||
|
const listeners: Entry[] = [];
|
||||||
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((type: string, fn: (e: Event) => void) => {
|
addEventListener: vi.fn(
|
||||||
(swListeners[type] ??= []).push(fn);
|
(type: string, fn: (e: Event) => void, opts?: AddEventListenerOptions | boolean) => {
|
||||||
})
|
if (type !== 'controllerchange') return;
|
||||||
|
const once = typeof opts === 'object' ? !!opts.once : false;
|
||||||
|
listeners.push({ fn, once });
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { registration, container: { swListeners } };
|
const fireControllerChange = () => {
|
||||||
|
const snap = [...listeners];
|
||||||
|
for (const e of snap) {
|
||||||
|
e.fn(new Event('controllerchange'));
|
||||||
|
if (e.once) {
|
||||||
|
const i = listeners.indexOf(e);
|
||||||
|
if (i >= 0) listeners.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { registration, fireControllerChange };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush(ms = 20): Promise<void> {
|
async function flush(ms = 20): Promise<void> {
|
||||||
await new Promise((r) => setTimeout(r, ms));
|
await new Promise((r) => setTimeout(r, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubLocationReload(): ReturnType<typeof vi.fn> {
|
||||||
|
const reload = vi.fn();
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: { ...window.location, reload }
|
||||||
|
});
|
||||||
|
return reload;
|
||||||
|
}
|
||||||
|
|
||||||
describe('pwa store', () => {
|
describe('pwa store', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING', async () => {
|
it('zombie-waiter (gleiche Version): kein Toast, silent SKIP_WAITING, KEIN Reload', async () => {
|
||||||
const active = new FakeSW('1776527907402');
|
const active = new FakeSW('1776527907402');
|
||||||
const waiting = new FakeSW('1776527907402');
|
const waiting = new FakeSW('1776527907402');
|
||||||
waiting.state = 'installed';
|
waiting.state = 'installed';
|
||||||
mountFakeSW({ active, waiting });
|
const { fireControllerChange } = mountFakeSW({ active, waiting });
|
||||||
|
const reload = stubLocationReload();
|
||||||
|
|
||||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||||
await pwaStore.init();
|
await pwaStore.init();
|
||||||
@@ -71,6 +96,12 @@ describe('pwa store', () => {
|
|||||||
|
|
||||||
expect(pwaStore.updateAvailable).toBe(false);
|
expect(pwaStore.updateAvailable).toBe(false);
|
||||||
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
expect(waiting.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' });
|
||||||
|
// Kritisch: selbst wenn der Browser nach dem silent SKIP_WAITING
|
||||||
|
// controllerchange feuert, darf kein Auto-Reload passieren — sonst
|
||||||
|
// Endlos-Loop, weil der nächste Seitenaufruf erneut einen Zombie
|
||||||
|
// bekommt.
|
||||||
|
fireControllerChange();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('echtes Update (unterschiedliche Version): Toast', async () => {
|
it('echtes Update (unterschiedliche Version): Toast', async () => {
|
||||||
@@ -108,17 +139,12 @@ describe('pwa store', () => {
|
|||||||
expect(pwaStore.updateAvailable).toBe(false);
|
expect(pwaStore.updateAvailable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reload() postet SKIP_WAITING, reload erst bei controllerchange', async () => {
|
it('reload() postet SKIP_WAITING, reload einmalig bei controllerchange', async () => {
|
||||||
const active = new FakeSW('v1');
|
const active = new FakeSW('v1');
|
||||||
const waiting = new FakeSW('v2');
|
const waiting = new FakeSW('v2');
|
||||||
waiting.state = 'installed';
|
waiting.state = 'installed';
|
||||||
const { container } = mountFakeSW({ active, waiting });
|
const { fireControllerChange } = mountFakeSW({ active, waiting });
|
||||||
|
const reload = stubLocationReload();
|
||||||
const reload = vi.fn();
|
|
||||||
Object.defineProperty(window, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
value: { ...window.location, reload }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||||
await pwaStore.init();
|
await pwaStore.init();
|
||||||
@@ -128,41 +154,17 @@ 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();
|
||||||
|
|
||||||
container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange')));
|
fireControllerChange();
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshing-Flag unterdrückt mehrfache Reloads', async () => {
|
// Nochmal controllerchange → wegen { once: true } kein zweiter Reload.
|
||||||
const active = new FakeSW('v1');
|
fireControllerChange();
|
||||||
const waiting = new FakeSW('v2');
|
|
||||||
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();
|
|
||||||
|
|
||||||
pwaStore.reload();
|
|
||||||
const fire = () =>
|
|
||||||
container.swListeners['controllerchange']?.forEach((fn) => fn(new Event('controllerchange')));
|
|
||||||
fire();
|
|
||||||
fire();
|
|
||||||
expect(reload).toHaveBeenCalledTimes(1);
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => {
|
it('reload() ohne waiting-SW ruft location.reload() sofort auf', async () => {
|
||||||
mountFakeSW({ active: new FakeSW('v1') });
|
mountFakeSW({ active: new FakeSW('v1') });
|
||||||
const reload = vi.fn();
|
const reload = stubLocationReload();
|
||||||
Object.defineProperty(window, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
value: { ...window.location, reload }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
const { pwaStore } = await import('../../src/lib/client/pwa.svelte');
|
||||||
await pwaStore.init();
|
await pwaStore.init();
|
||||||
|
|||||||
Reference in New Issue
Block a user