Optimierung automatische Updates der Webanwendung bei Aktualisierung der Anwendung auf dem Server

This commit is contained in:
2026-08-10 11:04:00 +02:00
parent a917c5f64e
commit 7b99a2d4b5
5 changed files with 229 additions and 12 deletions

View File

@@ -329,11 +329,40 @@ stehenbleiben, die nie angekommen ist. Diese Stelle (`mutate()` in
Speichert nur die Programmdateien, **nicht** die API-Antworten. Eine veraltete Speichert nur die Programmdateien, **nicht** die API-Antworten. Eine veraltete
Liste aus dem Cache wäre schlimmer als gar keine. Das kontrollierte Vorhalten Liste aus dem Cache wäre schlimmer als gar keine. Das kontrollierte Vorhalten
der Daten übernimmt in Phase 4 IndexedDB. der Daten übernimmt IndexedDB.
Bei Änderungen an den Dateien in `SHELL` muss `VERSION` in `sw.js` hochgezählt Bei Änderungen an den Dateien in `SHELL` muss `VERSION` in `sw.js` hochgezählt
werden, sonst behalten installierte Clients die alte Fassung. werden, sonst behalten installierte Clients die alte Fassung.
### Aktualisierung installierter Apps
Eine installierte PWA wird oft wochenlang nicht vollständig geschlossen. Der
Browser lädt den Service Worker zwar im Hintergrund neu, aber der neue bleibt
im Wartezustand, bis alle Fenster geschlossen sind und die laufende Seite
arbeitet weiter mit dem alten JavaScript. Für den Nutzer sieht es aus, als käme
das Update nie an.
`web/html/js/update.js` löst das:
1. Nachfragen, ob etwas bereitliegt beim Zurückkehren in den Vordergrund,
bei Verbindungsrückkehr und alle 30 Minuten
2. Wenn ja: unten ein Hinweis mit „Jetzt neu laden" und einem Kreuz zum
Wegklicken
3. Auf Klick übernimmt der neue Service Worker, und über `controllerchange`
lädt die Seite **einmal** neu auch in weiteren offenen Fenstern
**`skipWaiting()` steht nicht mehr im `install`-Ereignis.** Genau dort war der
Fehler: Der neue Service Worker übernahm sofort die Kontrolle, während die
geladene Seite noch mit dem alten JavaScript lief gemischte Stände, und
aktualisiert wurde trotzdem erst beim nächsten vollständigen Schließen. Jetzt
wartet er, bis die Anwendung ihn ausdrücklich dazu auffordert.
**Kein ungefragtes Neuladen.** Ein erzwungener Neustart mitten in einer Eingabe
würde den halb getippten Artikelnamen verlieren. Die Daten selbst sind sicher
sie liegen in der Outbox , aber der Ärger wäre trotzdem da. Wer den Hinweis
wegklickt, bekommt die neue Fassung beim nächsten Start; das geschieht ohnehin,
sobald alle Fenster geschlossen sind.
### Auf dem Telefon installieren ### Auf dem Telefon installieren
Android/Chrome bietet „Zur Startseite hinzufügen" von selbst an. Bei iOS geht Android/Chrome bietet „Zur Startseite hinzufügen" von selbst an. Bei iOS geht

View File

@@ -1577,3 +1577,59 @@ select.category-select {
man sehen, ohne hinzusehen. */ man sehen, ohne hinzusehen. */
select.market-select:not([data-empty]), select.market-select:not([data-empty]),
select.category-select:not([data-empty]) { border-color: var(--accent); } select.category-select:not([data-empty]) { border-color: var(--accent); }
/* ===================================================================
Hinweis auf eine neue Fassung
=================================================================== */
.update-banner {
position: fixed;
z-index: 80;
left: 1rem;
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
max-width: 28rem;
margin: 0 auto;
display: flex;
align-items: center;
gap: .6rem;
background: var(--card);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: .7rem .8rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, .22);
font-size: .92rem;
}
.update-text { flex: 1; min-width: 0; }
button.update-apply {
flex: 0 0 auto;
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: var(--radius);
padding: .45rem .8rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button.update-later {
flex: 0 0 auto;
background: none;
border: none;
color: var(--muted);
font-size: 1.2rem;
line-height: 1;
padding: .2rem .4rem;
cursor: pointer;
}
/* Über dem Sucher und über Dialogen hat der Hinweis nichts zu suchen. */
body.scanning .update-banner,
body.modal-open .update-banner { display: none; }
@media print { .update-banner { display: none !important; } }

View File

@@ -24,6 +24,7 @@ import { manageView } from "./views/manage.js";
import { closeMenu } from "./appbar.js"; import { closeMenu } from "./appbar.js";
import * as db from "./db.js"; import * as db from "./db.js";
import { installTriggers, stopWatching } from "./sync.js"; import { installTriggers, stopWatching } from "./sync.js";
import { watchForUpdates } from "./update.js";
import { publicView } from "./views/public.js"; import { publicView } from "./views/public.js";
import { acceptInviteView, shareView } from "./views/share.js"; import { acceptInviteView, shareView } from "./views/share.js";
@@ -276,13 +277,9 @@ async function start() {
await route(); await route();
if ("serviceWorker" in navigator) { // Registriert den Service Worker und meldet sich, wenn eine neue
try { // Fassung bereitliegt.
await navigator.serviceWorker.register("/sw.js", { scope: "/" }); watchForUpdates();
} catch (err) {
console.warn("Service Worker nicht registriert:", err.message);
}
}
} }
start(); start();

119
web/html/js/update.js Normal file
View File

@@ -0,0 +1,119 @@
// Erkennt neue Fassungen der Anwendung und wendet sie an.
//
// Warum das nötig ist: Eine installierte PWA wird oft wochenlang nicht
// vollständig geschlossen. Der Browser lädt zwar den Service Worker im
// Hintergrund neu, aber der neue bleibt im Wartezustand, bis alle
// Fenster geschlossen sind - und die laufende Seite arbeitet weiter mit
// dem alten JavaScript. Für den Nutzer sieht es aus, als käme das
// Update nie an.
//
// Ablauf hier:
// 1. Regelmäßig nachfragen, ob eine neue Fassung bereitliegt
// 2. Wenn ja: Hinweis einblenden, nicht ungefragt neu laden
// 3. Auf Wunsch den Wechsel anfordern und danach EINMAL neu laden
"use strict";
import { el } from "./dom.js";
// Wie oft im laufenden Betrieb nachgefragt wird. Der Browser prüft von
// sich aus nur bei Navigationen - in einer installierten PWA kann das
// sehr lange dauern.
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
let reloading = false;
let banner = null;
function showBanner(onApply) {
if (banner) return;
banner = el("div.update-banner", { role: "status" },
el("span.update-text", {},
"Eine neue Fassung der App ist bereit."),
el("button.update-apply", { type: "button", onclick: onApply },
"Jetzt neu laden"),
el("button.update-later", {
type: "button",
"aria-label": "Hinweis ausblenden",
title: "Später die neue Fassung wird beim nächsten Start aktiv",
onclick: () => {
banner.remove();
banner = null;
},
}, "×")
);
document.body.append(banner);
}
/**
* Registriert den Service Worker und überwacht Aktualisierungen.
*
* Gibt nichts zurück; Fehler werden geschluckt, weil eine fehlende
* Aktualisierungsprüfung die Anwendung nicht unbenutzbar machen darf.
*/
export async function watchForUpdates() {
if (!("serviceWorker" in navigator)) return;
let registration;
try {
registration = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch (err) {
console.warn("Service Worker nicht registriert:", err.message);
return;
}
/** Fordert den wartenden Service Worker auf, zu übernehmen. Das
* eigentliche Neuladen löst danach das controllerchange-Ereignis
* aus - so wird auch in anderen offenen Fenstern neu geladen. */
function apply() {
const waiting = registration.waiting;
if (!waiting) {
// Sollte nicht vorkommen; dann eben unmittelbar neu laden.
window.location.reload();
return;
}
waiting.postMessage({ type: "SKIP_WAITING" });
}
function considerWaiting() {
// Ohne controller ist es die Erstinstallation - da gibt es nichts
// zu ersetzen, und ein Hinweis wäre verwirrend.
if (registration.waiting && navigator.serviceWorker.controller) {
showBanner(apply);
}
}
// Beim Start: Liegt schon etwas bereit?
considerWaiting();
registration.addEventListener("updatefound", () => {
const incoming = registration.installing;
if (!incoming) return;
incoming.addEventListener("statechange", () => {
if (incoming.state === "installed") considerWaiting();
});
});
// Genau einmal neu laden, wenn der neue Service Worker übernimmt.
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (reloading) return;
reloading = true;
window.location.reload();
});
const check = () => {
// Ohne Verbindung wäre die Abfrage nur ein Fehler im Protokoll.
if (!navigator.onLine) return;
registration.update().catch(() => {});
};
// Beim Zurückkehren in den Vordergrund - der häufigste Moment, in dem
// eine installierte PWA nach Tagen wieder benutzt wird.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") check();
});
window.addEventListener("online", check);
setInterval(check, CHECK_INTERVAL_MS);
check();
}

View File

@@ -5,7 +5,7 @@
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet, // Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
// wann alte Zwischenspeicher verworfen werden. // wann alte Zwischenspeicher verworfen werden.
const VERSION = "v30"; const VERSION = "v31";
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`; const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
const SHELL = [ const SHELL = [
@@ -18,6 +18,7 @@ const SHELL = [
"/js/dom.js", "/js/dom.js",
"/js/store.js", "/js/store.js",
"/js/appbar.js", "/js/appbar.js",
"/js/update.js",
"/js/db.js", "/js/db.js",
"/js/sync.js", "/js/sync.js",
"/js/push.js", "/js/push.js",
@@ -41,13 +42,28 @@ const SHELL = [
]; ];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
// KEIN skipWaiting hier.
//
// Vorher stand es an dieser Stelle - mit der Folge, dass der neue
// Service Worker sofort die Kontrolle übernahm, während die bereits
// geladene Seite mit dem alten JavaScript weiterlief. Ergebnis:
// gemischte Stände, und aktualisiert wurde trotzdem erst beim
// nächsten vollständigen Schließen der App.
//
// Jetzt wartet er, bis die Anwendung ihn ausdrücklich dazu auffordert
// (Nachricht "SKIP_WAITING") oder bis alle Fenster geschlossen sind.
event.waitUntil( event.waitUntil(
caches.open(SHELL_CACHE) caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL))
.then((cache) => cache.addAll(SHELL))
.then(() => self.skipWaiting())
); );
}); });
// Die Anwendung fordert den Wechsel an, sobald der Nutzer zugestimmt hat.
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys() caches.keys()