fix: Update-Hinweis vergleicht gegen den laufenden Build (D45)

Der Hinweis erschien oft für genau die Fassung, die gerade geladen war. Zwei
Fehler mit derselben Wurzel — die Prüfung verglich nie mit dem laufenden Stand:

- Relais statt Vergleich: der abgerufene Inhalts-Hash wurde gegen den ZULETZT
  ABGERUFENEN gehalten (localStorage), nicht gegen den laufenden. Beim Deploy
  antworten CDN-Knoten unterschiedlich; jeder Wechsel schlug an, und der
  Schlüssel wird von allen Tabs geteilt.
- Klebendes Flag: `werkbaum-update-available` entfernte nur „Jetzt laden".
  Nach F5 oder „Später" blieb es stehen, und der Load-Check zeigte das Banner
  bei jedem weiteren Laden erneut.

Verglichen wird jetzt der Commit aus dem Footer-Versionslink der laufenden
Seite gegen den der abgerufenen HTML (D16 spritzt ihn in beiden Deploy-Wegen
ein). Kein localStorage dazwischen; beim Laden wird nichts gemeldet, die
Prüfung zwei Sekunden später holt es nach. Eine spätere „aktuell"-Meldung
sammelt Banner und Footer-Symbol wieder ein. Rückfall auf den Inhalts-Hash
für Builds ohne Marker (Dev-Server), Vergleichsstand dort der erste Abruf
der Seiten-Sitzung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-23 11:36:02 +02:00
co-authored by Claude Fable 5
parent 579dfbf789
commit f1ac57c931
3 changed files with 170 additions and 28 deletions
+85
View File
@@ -2824,3 +2824,88 @@ hervor, dieser Markensatz ist also nicht schreibbar. Das Bild stimmt trotzdem
einem Plan ohne `!!!` werden die Marken geschrieben: `- [ ] > Mittel (M)`, einem Plan ohne `!!!` werden die Marken geschrieben: `- [ ] > Mittel (M)`,
`- [ ] > Klein (S)` und `- [ ] > Teil 3 (M)`, während `(L)`, `(XL)` und der `- [ ] > Klein (S)` und `- [ ] > Teil 3 (M)`, während `(L)`, `(XL)` und der
Knoten ohne Größe offen bleiben. Knoten ohne Größe offen bleiben.
## D45 — Update-Prüfung vergleicht gegen den laufenden Build, nicht gegen einen gemerkten Abruf
Gemeldet: „Oft wird der Hinweis, dass eine neue Version vorliege, oben
angezeigt, obwohl genau die bereits geladen wurde“ — mit dem Verdacht auf eine
Race-Condition und der Vermutung, dass es in der Vorschau-Instanz deshalb
immer wieder auftritt. Beides trifft zu, und es sind **zwei** Fehler, die
dieselbe Wurzel haben: Die Prüfung verglich nie mit dem Stand, der gerade
läuft.
**Wurzel: ein Relais statt eines Vergleichs.** `checkForUpdates()` holte die
Seite und verglich ihren Inhalts-Hash mit `werkbaum-html-hash` — dem Hash des
**zuletzt abgerufenen** Stands. Über den läuft die Aussage „neu“ also
indirekt: Sie sagt „der Server liefert etwas anderes als beim letzten Abruf“,
nicht „der Server liefert etwas anderes als das, was du vor dir hast“. Das
sind verschiedene Aussagen, sobald etwas zwischen Laden und Abruf dazwischen
kommt — und genau das tut ein CDN. Der Kommentar im Code hielt schon fest,
dass GitHub Pages je Cache-Knoten abweichende ETags liefert; dasselbe gilt
zeitlich für den **Inhalt**: Während ein Deploy durchläuft, antworten Knoten
unterschiedlich, aufeinanderfolgende Abrufe wechseln zwischen alt und neu. Der
Hash im localStorage wurde dabei bei **jedem** Abruf nachgeführt, jeder Wechsel
schlug also erneut an — auch wenn der laufende Tab längst den neuen Stand
hatte. Dazu kommt, dass sich alle Tabs denselben Schlüssel teilen: Zwei
geöffnete Tabs schreiben abwechselnd ihren Stand hinein und melden einander
Updates.
**Zweiter Fehler, der eigentliche Dauerbrenner: das Flag blieb kleben.**
`werkbaum-update-available` wurde bei einem Fund gesetzt und **nur** vom Knopf
„Jetzt laden“ wieder entfernt. Wer statt dessen F5 drückte — oder „Später“, das
lediglich das Banner-Element entfernte —, behielt das Flag; und beim Laden
stand:
```js
if(!document.hidden && localStorage.getItem('werkbaum-update-available')){
checkAndShowUpdateNotification();
}
```
Also erschien das Banner ausgerechnet auf der Fassung, die es gerade eingespielt
hatte, und danach bei **jedem** weiteren Laden, bis irgendwann jemand den
richtigen Knopf traf. Nachgestellt: Flag setzen, neu laden → Banner; „Später“ →
Flag steht weiter; neu laden → Banner. Endlos.
**Entscheidung: die laufende Seite ist der Vergleichsmaßstab, und sie kennt
sich selbst.** Beide Deploy-Wege spritzen den Commit in den
Footer-Versionslink (D16, `<a class="ver" href="…/commit/<sha>">`) — die
laufende Seite trägt ihre Identität also im DOM, die abgerufene im HTML-Text.
Zwei Werte, die im selben Moment vorliegen; dazwischen kein Speicher, der
altern könnte. Damit sind alle drei Ursachen weg: kein Relais (CDN-Flattern
meldet nichts mehr, solange der gelieferte Commit der laufende ist), keine
Kopplung zwischen Tabs, und nichts, was ein Neuladen überdauert. Nebengewinn:
Schon die **erste** Prüfung nach dem Laden ist aussagekräftig — der alte Weg
konnte beim ersten Mal grundsätzlich nichts sagen, weil er erst einen
Vergleichsstand anlegen musste.
**Der Zustand lebt nur noch im Speicher.** `werkbaum-update-available` und
`werkbaum-html-hash` werden nicht mehr geschrieben (der Reset räumt sie noch
weg, falls sie aus einer früheren Fassung herumliegen). Beim Laden wird
grundsätzlich **nichts** gemeldet: Was der Browser gerade geholt hat, *ist* der
aktuelle Stand, bis eine Prüfung etwas anderes zeigt — und die läuft zwei
Sekunden später ohnehin. Damit kann die Meldung nicht mehr klemmen: Gilt sie
noch, kommt sie sofort wieder; gilt sie nicht, bleibt sie weg. „Später“ darf
deshalb weiterhin nur das Element entfernen.
**Die Meldung wird auch wieder eingesammelt.** Sagt eine spätere Prüfung
„aktuell“, während das Banner steht, verschwinden Banner und Footer-Symbol.
Das deckt den Rollback ab und den Fall, dass ein einzelner Abruf doch einmal
gegen einen veralteten Knoten lief.
**Rückfall für Builds ohne Marker** (Dev-Server, `file://`, lokales
`npm run preview`): dort steht im Footer der Platzhalter `…/commit/main`, also
keine Commit-Kennung. Dann wird weiter der Inhalts-Hash verglichen — aber gegen
den **ersten Abruf dieser Seiten-Sitzung**, der als Vergleichsstand stehen
bleibt, statt gegen einen fortlaufend nachgeführten Wert im localStorage. Der
Preis ist ein blindes Fenster von zwei Sekunden zwischen Laden und Erstprüfung;
auf dem Dev-Server ist das gleichgültig, weil dort HMR arbeitet.
**Nachgemessen** (Dev-Server, Marker zum Prüfen von Hand eingespritzt):
| Fall | vorher | nachher |
|---|---|---|
| Altes Flag im localStorage, neu laden | Banner, bei jedem Laden erneut | kein Banner, drei Takte „✓ Alles aktuell“ |
| Gespeicherter Hash ≠ Auslieferung, Seite unverändert | „✅ NEUE VERSION ERKANNT!“ | „✓ Alles aktuell“ |
| Server liefert anderen Commit | — | „✅ Neuer Build 2222222“, Banner + Footer-Symbol |
| Danach wieder derselbe Commit | Banner blieb stehen | Banner und Symbol verschwinden |
| „Später“, dann F5 | Banner sofort wieder da | weg und bleibt weg |
+13
View File
@@ -491,3 +491,16 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Diagramm maximiert. Layout-CSS hängt an `body.mobile`, nicht an einer eigenen Diagramm maximiert. Layout-CSS hängt an `body.mobile`, nicht an einer eigenen
`@media`-Regel — beide Seiten müssen denselben 640-px-Schwellwert nutzen `@media`-Regel — beide Seiten müssen denselben 640-px-Schwellwert nutzen
(SPEC §9, D17). (SPEC §9, D17).
- **Update-Prüfung (D45):** verglichen wird der Commit aus dem
Footer-Versionslink der **laufenden** Seite (`runningBuildId()`, DOM) gegen
den aus der abgerufenen HTML (`buildIdFromHtml()`). Kein localStorage
dazwischen — `werkbaum-update-available`/`werkbaum-html-hash` werden **nicht
mehr geschrieben** (der Reset räumt sie nur noch weg). Wer hier etwas
„merken" will: Genau das war der Fehler — ein gemerktes „Update verfügbar"
überlebt das Neuladen, das es einspielt, und meldet dieselbe Fassung endlos
weiter. Beim Laden wird deshalb **nichts** gemeldet; die Prüfung nach zwei
Sekunden holt es nach. Zum Testen: Auf dem Dev-Server steht im Footer der
Platzhalter `…/commit/main` (keine Commit-Kennung) — dort läuft der
Hash-Rückfall, der Marker-Pfad ist nur zu prüfen, wenn man einen echten
`commit/<sha>` in `index.html` einspritzt (danach zurücknehmen!) und
`window.fetch` überschreibt.
+72 -28
View File
@@ -3321,7 +3321,35 @@ mountBuildBadge();
ALLEN Builds aktiv. */ ALLEN Builds aktiv. */
const isProdBuild = import.meta.env.VITE_BUILD_BADGE === 'none'; const isProdBuild = import.meta.env.VITE_BUILD_BADGE === 'none';
/* Kompakter, deterministischer Hash (cyrb53) über den GESAMTEN HTML-Text. /* Beides bewusst NUR im Speicher, nicht im localStorage (D45): Ein gemerktes
Update verfügbar" überlebte das Neuladen, das es gerade eingespielt hat
und meldete dieselbe Fassung wieder und wieder. Nach dem Laden ist nichts
bekannt; was noch gilt, findet die nächste Prüfung zwei Sekunden später. */
let updateAvailable = false;
let baselineHash = null;
/* Verglichen wird gegen den LAUFENDEN Build, nicht gegen einen gemerkten Abruf.
Beide Deploy-Wege spritzen den Commit in den Footer-Versionslink (D16); die
laufende Seite trägt ihn also selbst, und die abgerufene Seite auch. Zwei
Werte, die im selben Moment vorliegen kein localStorage dazwischen, damit
nichts hängenbleiben und nichts zwischen Tabs durcheinandergeraten kann.
Siehe D45. */
function buildIdFromHtml(html){
const m = html.match(/<a class="ver"[^>]*href="[^"]*\/commit\/([0-9a-f]{7,40})/);
return m ? m[1] : null;
}
let runningBuild;
function runningBuildId(){
if(runningBuild === undefined){
const el = document.querySelector('.site-footer .ver');
const m = el && (el.getAttribute('href') || '').match(/\/commit\/([0-9a-f]{7,40})\b/);
runningBuild = m ? m[1] : null; /* Platzhalter „…/commit/main" ⇒ null */
}
return runningBuild;
}
/* Rückfall für Builds ohne eingespritzten Commit (Dev-Server, `file://`):
Kompakter, deterministischer Hash (cyrb53) über den GESAMTEN HTML-Text.
Wichtig: Der frühere Ansatz erste 300 + letzte 300 Zeichen" verfehlte JEDE Wichtig: Der frühere Ansatz erste 300 + letzte 300 Zeichen" verfehlte JEDE
Änderung die Seite ist eine self-contained Datei (D19), der komplette Änderung die Seite ist eine self-contained Datei (D19), der komplette
App-Code UND die Footer-Version liegen als inline-Bundle in der MITTE, also App-Code UND die Footer-Version liegen als inline-Bundle in der MITTE, also
@@ -3349,25 +3377,44 @@ async function checkForUpdates(){
const resp = await fetch(bust.href, { cache: 'no-store' }); const resp = await fetch(bust.href, { cache: 'no-store' });
if(!resp.ok) throw new Error(`HTTP ${resp.status}`); if(!resp.ok) throw new Error(`HTTP ${resp.status}`);
/* Voller Content-Hash ist die alleinige Wahrheit. ETag/Last-Modified werden /* ETag/Last-Modified werden bewusst NICHT herangezogen: GitHub Pages liefert
bewusst NICHT mehr herangezogen: GitHub Pages liefert je Cache-Knoten je Cache-Knoten unterschiedliche ETags für identischen Inhalt und löste
unterschiedliche ETags für identischen Inhalt und löste damit die damit die irreführende Meldung Metadaten geändert, aber Inhalt gleich" aus. */
irreführende Meldung Metadaten geändert, aber Inhalt gleich" aus. */
const html = await resp.text(); const html = await resp.text();
const hash = hashContent(html); const laufend = runningBuildId(), geliefert = buildIdFromHtml(html);
const stored = localStorage.getItem('werkbaum-html-hash'); let neu;
if(!stored){ if(laufend && geliefert){
logUpdate('✓ Erste Prüfung Hash gespeichert'); neu = geliefert !== laufend;
} else if(hash === stored){ logUpdate(neu ? '✅ Neuer Build ' + geliefert.slice(0, 7) : '✓ Alles aktuell');
logUpdate('✓ Alles aktuell');
} else { } else {
localStorage.setItem('werkbaum-update-available', 'true'); /* Ohne Marker bleibt nur der Inhalts-Hash. Vergleichsstand ist der ERSTE
logUpdate('✅ NEUE VERSION ERKANNT!'); Abruf dieser Seiten-Sitzung und bleibt es er im localStorage
if(!document.hidden) checkAndShowUpdateNotification(); nachgeführt hieße: ein einzelner Abruf gegen einen veralteten
CDN-Knoten setzt den Stand um, und der nächste meldet fälschlich neu. */
const hash = hashContent(html);
if(baselineHash === null){
baselineHash = hash;
neu = false;
logUpdate('✓ Erste Prüfung Vergleichsstand gesetzt');
} else {
neu = hash !== baselineHash;
logUpdate(neu ? '✅ NEUE VERSION ERKANNT!' : '✓ Alles aktuell');
}
} }
localStorage.setItem('werkbaum-html-hash', hash); if(neu && !updateAvailable){
updateAvailable = true;
if(!document.hidden) checkAndShowUpdateNotification();
else updateFooterUpdateIcon();
} else if(!neu && updateAvailable){
/* Zurückgenommen (Rollback, oder der Abruf lief vorher gegen einen
veralteten Knoten): Meldung wieder einsammeln, statt sie stehenzulassen. */
updateAvailable = false;
const notif = document.getElementById('updateNotification');
if(notif) notif.remove();
updateFooterUpdateIcon();
}
} catch(err) { } catch(err) {
const msg = err.message || err.toString(); const msg = err.message || err.toString();
if(msg.includes('Failed to fetch')) { if(msg.includes('Failed to fetch')) {
@@ -3457,16 +3504,12 @@ document.addEventListener('visibilitychange', () => {
if(!document.hidden){ if(!document.hidden){
checkForUpdates(); checkForUpdates();
showUpdateDebug(); showUpdateDebug();
if(localStorage.getItem('werkbaum-update-available')){ if(updateAvailable) checkAndShowUpdateNotification();
checkAndShowUpdateNotification();
}
} }
}); });
/* Prüfe beim Laden, falls Update bereits verfügbar */ /* Beim Laden wird NICHT gemeldet: Was die Seite gerade geladen hat, IST der
if(!document.hidden && localStorage.getItem('werkbaum-update-available')){ aktuelle Stand, bis eine Prüfung etwas anderes zeigt (D45). */
checkAndShowUpdateNotification();
}
function checkAndShowUpdateNotification(){ function checkAndShowUpdateNotification(){
const existingNotif = document.getElementById('updateNotification'); const existingNotif = document.getElementById('updateNotification');
@@ -3519,7 +3562,6 @@ function checkAndShowUpdateNotification(){
document.body.insertBefore(notif, document.body.firstChild); document.body.insertBefore(notif, document.body.firstChild);
notif.querySelector('.updateBtn').addEventListener('click', () => { notif.querySelector('.updateBtn').addEventListener('click', () => {
localStorage.removeItem('werkbaum-update-available');
window.location.reload(); window.location.reload();
}); });
@@ -3539,8 +3581,10 @@ function resetToDefaults(){
if(!confirmed) return; if(!confirmed) return;
/* Nicht-Dokument-Zustand auf Defaults (UI, Sprache, Update-Flags) die /* Nicht-Dokument-Zustand auf Defaults (UI, Sprache, Update-Log) die
Dokumentenliste (werkbaum-docs) bleibt erhalten (D22). */ Dokumentenliste (werkbaum-docs) bleibt erhalten (D22). Die beiden
Update-Schlüssel schreibt niemand mehr (D45); sie werden nur noch
aufgeräumt, falls sie aus einer früheren Fassung herumliegen. */
['werkbaum-ui','werkbaum-lang','werkbaum-html-hash','werkbaum-update-available','werkbaum-update-log'] ['werkbaum-ui','werkbaum-lang','werkbaum-html-hash','werkbaum-update-available','werkbaum-update-log']
.forEach(k => { try{ localStorage.removeItem(k); }catch(_){} }); .forEach(k => { try{ localStorage.removeItem(k); }catch(_){} });
@@ -3572,7 +3616,7 @@ function resetToDefaults(){
/* Update-Benachrichtigung-Symbol im Footer (rechts neben Version) */ /* Update-Benachrichtigung-Symbol im Footer (rechts neben Version) */
function updateFooterUpdateIcon(){ function updateFooterUpdateIcon(){
let icon = document.getElementById('footerUpdateIcon'); let icon = document.getElementById('footerUpdateIcon');
const hasUpdate = localStorage.getItem('werkbaum-update-available'); const hasUpdate = updateAvailable;
if(hasUpdate && !icon){ if(hasUpdate && !icon){
const footer = document.querySelector('.site-footer'); const footer = document.querySelector('.site-footer');
@@ -3609,8 +3653,8 @@ function updateFooterUpdateIcon(){
} }
} }
/* Icon beim Laden und bei Update-Erkennung aktualisieren */ /* Icon bei Update-Erkennung aktualisieren. Beim Laden gibt es nichts zu zeigen
document.addEventListener('DOMContentLoaded', updateFooterUpdateIcon); der Zustand lebt nur in dieser Seiten-Sitzung (D45). */
const originalCheckAndShowUpdateNotification = checkAndShowUpdateNotification; const originalCheckAndShowUpdateNotification = checkAndShowUpdateNotification;
checkAndShowUpdateNotification = function(){ checkAndShowUpdateNotification = function(){
originalCheckAndShowUpdateNotification.call(this); originalCheckAndShowUpdateNotification.call(this);