diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1f83e00..cb41ea3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2824,3 +2824,88 @@ hervor, dieser Markensatz ist also nicht schreibbar. Das Bild stimmt trotzdem einem Plan ohne `!!!` werden die Marken geschrieben: `- [ ] > Mittel (M)`, `- [ ] > Klein (S)` und `- [ ] > Teil 3 (M)`, während `(L)`, `(XL)` und der 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, ``) — 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 | diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 49ca336..2c1cfef 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 `@media`-Regel — beide Seiten müssen denselben 640-px-Schwellwert nutzen (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/` in `index.html` einspritzt (danach zurücknehmen!) und + `window.fetch` überschreibt. diff --git a/frontend/src/app.js b/frontend/src/app.js index e49a803..066c3a4 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -3321,7 +3321,35 @@ mountBuildBadge(); ALLEN Builds aktiv. */ 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(/]*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 Ä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 @@ -3349,25 +3377,44 @@ async function checkForUpdates(){ const resp = await fetch(bust.href, { cache: 'no-store' }); if(!resp.ok) throw new Error(`HTTP ${resp.status}`); - /* Voller Content-Hash ist die alleinige Wahrheit. ETag/Last-Modified werden - bewusst NICHT mehr herangezogen: GitHub Pages liefert je Cache-Knoten - unterschiedliche ETags für identischen Inhalt und löste damit die - irreführende Meldung „Metadaten geändert, aber Inhalt gleich" aus. */ + /* ETag/Last-Modified werden bewusst NICHT herangezogen: GitHub Pages liefert + je Cache-Knoten unterschiedliche ETags für identischen Inhalt und löste + damit die irreführende Meldung „Metadaten geändert, aber Inhalt gleich" aus. */ const html = await resp.text(); - const hash = hashContent(html); - const stored = localStorage.getItem('werkbaum-html-hash'); + const laufend = runningBuildId(), geliefert = buildIdFromHtml(html); + let neu; - if(!stored){ - logUpdate('✓ Erste Prüfung – Hash gespeichert'); - } else if(hash === stored){ - logUpdate('✓ Alles aktuell'); + if(laufend && geliefert){ + neu = geliefert !== laufend; + logUpdate(neu ? '✅ Neuer Build ' + geliefert.slice(0, 7) : '✓ Alles aktuell'); } else { - localStorage.setItem('werkbaum-update-available', 'true'); - logUpdate('✅ NEUE VERSION ERKANNT!'); - if(!document.hidden) checkAndShowUpdateNotification(); + /* Ohne Marker bleibt nur der Inhalts-Hash. Vergleichsstand ist der ERSTE + Abruf dieser Seiten-Sitzung und bleibt es — er im localStorage + 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) { const msg = err.message || err.toString(); if(msg.includes('Failed to fetch')) { @@ -3457,16 +3504,12 @@ document.addEventListener('visibilitychange', () => { if(!document.hidden){ checkForUpdates(); showUpdateDebug(); - if(localStorage.getItem('werkbaum-update-available')){ - checkAndShowUpdateNotification(); - } + if(updateAvailable) checkAndShowUpdateNotification(); } }); -/* Prüfe beim Laden, falls Update bereits verfügbar */ -if(!document.hidden && localStorage.getItem('werkbaum-update-available')){ - checkAndShowUpdateNotification(); -} +/* Beim Laden wird NICHT gemeldet: Was die Seite gerade geladen hat, IST der + aktuelle Stand, bis eine Prüfung etwas anderes zeigt (D45). */ function checkAndShowUpdateNotification(){ const existingNotif = document.getElementById('updateNotification'); @@ -3519,7 +3562,6 @@ function checkAndShowUpdateNotification(){ document.body.insertBefore(notif, document.body.firstChild); notif.querySelector('.updateBtn').addEventListener('click', () => { - localStorage.removeItem('werkbaum-update-available'); window.location.reload(); }); @@ -3539,8 +3581,10 @@ function resetToDefaults(){ if(!confirmed) return; - /* Nicht-Dokument-Zustand auf Defaults (UI, Sprache, Update-Flags) — die - Dokumentenliste (werkbaum-docs) bleibt erhalten (D22). */ + /* Nicht-Dokument-Zustand auf Defaults (UI, Sprache, Update-Log) — die + 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'] .forEach(k => { try{ localStorage.removeItem(k); }catch(_){} }); @@ -3572,7 +3616,7 @@ function resetToDefaults(){ /* Update-Benachrichtigung-Symbol im Footer (rechts neben Version) */ function updateFooterUpdateIcon(){ let icon = document.getElementById('footerUpdateIcon'); - const hasUpdate = localStorage.getItem('werkbaum-update-available'); + const hasUpdate = updateAvailable; if(hasUpdate && !icon){ const footer = document.querySelector('.site-footer'); @@ -3609,8 +3653,8 @@ function updateFooterUpdateIcon(){ } } -/* Icon beim Laden und bei Update-Erkennung aktualisieren */ -document.addEventListener('DOMContentLoaded', updateFooterUpdateIcon); +/* Icon bei Update-Erkennung aktualisieren. Beim Laden gibt es nichts zu zeigen — + der Zustand lebt nur in dieser Seiten-Sitzung (D45). */ const originalCheckAndShowUpdateNotification = checkAndShowUpdateNotification; checkAndShowUpdateNotification = function(){ originalCheckAndShowUpdateNotification.call(this);