fix(web): Updates kommen an, ohne den Cache von Hand zu leeren

Nach einem Update sah man die alte Anwendung -- und wer dann einen
Bereich anklickte, der erst beim Klick nachgeladen wird (Kalender,
PDF-Betrachter), bekam entweder gar nichts oder einen Fehler, der nach
einem Programmfehler aussieht ("e is not iterable"). Nachgemessen: Der
Browser lud index-BlqR-CVf.js, ausgeliefert wurde index-arO1lebV.js.

Ursache: Der Server schickte fuer index.html **gar kein**
Cache-Control. Fehlt es, schaetzt der Browser anhand von Last-Modified
selbst, wie lange die Antwort wohl gilt -- bei einer Datei, die seit
Tagen unveraendert war, sind das schnell Stunden. Er benutzte also
weiter die alte Seite, die auf Bausteine zeigt, die es nach dem Update
nicht mehr gibt. Auch die Fetches des Service Workers laufen durch
diesen Cache, weshalb selbst dessen "Netz zuerst" nicht half.

Jetzt die uebliche Zweiteilung, die sich aus den Dateinamen ergibt:

  /assets/…   public, max-age=31536000, immutable
              Der Inhalts-Hash steht im Namen. Anderer Inhalt = anderer
              Name, also darf der Browser sie fuer immer behalten.

  alles sonst no-cache
              index.html, sw.js, das Manifest. "no-cache" heisst nicht
              "nicht speichern", sondern "vor dem Benutzen rueckfragen" --
              mit ETag antwortet der Server meist mit 304 und ein paar
              Byte.

Dazu ein Sicherheitsnetz fuer den Fall, dass doch einmal ein alter
Baustein angefordert wird: vite:preloadError laedt die Seite einmal neu.
Einmal, per sessionStorage-Merkmal -- laege der Fehler woanders, drehte
sich die Seite sonst endlos im Kreis.

Das ist der Unterschied zwischen "ich leere eben den Cache" und einem
Menschen, der am Handy vor einer Seite sitzt, auf der nichts passiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-06 23:31:59 +02:00
1 parent ec8f162fd5
commit fce8053d09
2 files changed
+54

No files matched your search

+28
View File
@@ -69,6 +69,7 @@ func spaHandler(root string) http.Handler {
} }
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil { if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
setStaticCaching(w, upath)
fs.ServeHTTP(w, r) fs.ServeHTTP(w, r)
return return
} }
@@ -81,10 +82,37 @@ func spaHandler(root string) http.Handler {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
setStaticCaching(w, "/index.html")
http.ServeFile(w, r, index) http.ServeFile(w, r, index)
}) })
} }
// setStaticCaching entscheidet, was der Browser behalten darf.
//
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
// als rätselhafter Fehler mitten in der Anwendung.
//
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
//
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
// ändert sich der Name. Solche Dateien darf der Browser für immer
// behalten – "immutable" spart ihm sogar die Rückfrage.
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
// der Server dann meist nur mit 304 und ein paar Byte.
func setStaticCaching(w http.ResponseWriter, upath string) {
if strings.HasPrefix(upath, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
func main() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
+26
View File
@@ -5,6 +5,32 @@ import App from './App'
// @ts-ignore // @ts-ignore
import './index.css' import './index.css'
/**
* Sicherheitsnetz für den Fall, dass der Browser noch eine alte Fassung der
* Anwendung im Speicher hat und einen Baustein nachlädt, den es auf dem
* Server nicht mehr gibt.
*
* Die Bausteine tragen einen Hash im Namen; nach einem Update sind die alten
* fort. Wer die Seite offen liegen lässt und dann auf einen Bereich klickt,
* der erst dann geladen wird (der Kalender, der PDF-Betrachter), bekommt sonst
* einen Fehler, der nach einem Programmfehler aussieht – oder es passiert
* schlicht nichts.
*
* Einmal neu laden holt die neue Fassung. Das `sessionStorage`-Merkmal
* verhindert dabei das Schlimmere: Läge der Fehler doch woanders, drehte sich
* die Seite sonst endlos im Kreis.
*/
window.addEventListener('vite:preloadError', () => {
if (sessionStorage.getItem('aether-reloaded-for-update')) return
sessionStorage.setItem('aether-reloaded-for-update', '1')
window.location.reload()
})
window.addEventListener('load', () => {
// Erfolgreich geladen: Die Sperre darf wieder fallen, sonst hilft sie beim
// nächsten Update nicht mehr.
setTimeout(() => sessionStorage.removeItem('aether-reloaded-for-update'), 5000)
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />