class PwaStore { updateAvailable = $state(false); private registration: ServiceWorkerRegistration | null = null; private pollTimer: ReturnType | null = null; async init(): Promise { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; try { this.registration = await navigator.serviceWorker.ready; } catch { return; } if (!this.registration) return; // Wenn beim Mount schon ein waiting-SW existiert, ist das nicht // automatisch ein echtes Update: Der Browser behält manchmal einen // bit-identischen Zombie im waiting-Slot (Artefakt aus einer vorigen // Session). Erst ein Version-Vergleich klärt, ob der neue SW wirklich // 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()); // Alle 30 Minuten aktiv nach Updates fragen, damit der User sie auch // mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren. this.pollTimer = setInterval(() => { void this.registration?.update().catch(() => {}); }, 30 * 60_000); } private onUpdateFound(): void { const installing = this.registration?.installing; if (!installing) return; installing.addEventListener('statechange', () => { // 'installed' UND ein laufender controller = Update für bestehenden Tab. // (Ohne controller wäre das die erste Installation, kein Update.) if (installing.state !== 'installed' || !navigator.serviceWorker.controller) return; const active = this.registration?.active; if (active && active !== installing) { void this.evaluateWaiting(installing, active); } else { 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 { 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 { this.updateAvailable = false; const waiting = this.registration?.waiting; if (!waiting) { // Kein wartender SW — entweder war es nur eine Toast-Anzeige, oder // der SW ist schon aktiv. In beiden Fällen reicht ein Reload. location.reload(); return; } // Klassisches Pattern: User-Klick → SKIP_WAITING an den wartenden // SW → controllerchange feuert, wenn der neue SW übernimmt → dann // 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' }); } dismiss(): void { this.updateAvailable = false; } } function queryVersion(sw: ServiceWorker): Promise { 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();