fix(ssr): Cache-Schreibfehler schlucken, App-Start absichern

Dieselbe Vorlage, dieselbe Auslieferung: `context.db.create("ssr", …)` ist
ein Insert, mehrere parallele Anfragen nach einer Invalidierung erzeugen
"duplicate key", und die Verlierer antworten mit 500 — ohne #appContainer,
also bleibt die Seite beim Besucher leer. Der Fehler wird geschluckt, die
Seite geht raus.

Der Block ist byte-gleich zu dem im event-website-tibi-2026-Projekt. Der
Starter ist die Vorlage; zwei Varianten desselben Fixes wären genau die
Drift, die hier nicht stattfinden darf.

Dazu der Einstiegspunkt, der denselben Fehlerklasse-Vorfall hat:

    setupI18n().then(() => { hydrate(App, { target: appContainer }) })

Ein Fehler beim Laden der Locale-Datei oder ein werfendes hydrate()
lässt das ausgelieferte HTML stehen — ohne Signal, ohne Logeintrag, und für
einen Test nicht unterscheidbar von "noch nicht fertig". Jetzt steht der
Render in try/catch, der Fehler wird geloggt, und #appContainer bekommt
data-app-ready="error". Tests warten auf den Wert "true" und nennen bei
"error" die Ursache mit, statt still zu timeouten.
This commit is contained in:
2026-09-30 20:58:10 +00:00
parent 9d700bc2c6
commit 0004bc98a1
2 changed files with 67 additions and 13 deletions
+33 -8
View File
@@ -194,14 +194,39 @@ const APP_SERVER_MODULE_PATH = "../lib/app.server"
if (cacheIt && !noCache) { if (cacheIt && !noCache) {
// save cache // save cache
context.db.create("ssr", { //
path: url, // `context.db.create` ist ein Insert, kein Upsert. Nach einer
content: tpl, // Invalidierung rendern mehrere Anfragen dieselbe URL gleichzeitig,
// @ts-ignore // und alle schreiben denselben Eintrag. Der Unique-Index auf `path`
validUntil: context.ssrCacheValidUntil, // lässt genau einen gewinnen; die Verlierer bekommen "duplicate
// dependency strings: "col:id" for detail, "col:*" for list (deduplicated) // key".
dependencies: depsKeys, //
}) // Das wird hier bewusst geschluckt. Der Eintrag existiert dann
// schon — der Gewinner hat ihn geschrieben, es fehlt nichts. Und
// die Seite ist ohnehin fertig gerendert, `tpl` steht bereit.
//
// Wichtig ist das aus zwei Gründen: Ein durchschlagender Fehler
// liefert eine gute Seite als HTTP 500 aus, und ein
// 500-Dokument hat kein #appContainer — die Seite bleibt beim
// Besucher dann für immer leer, weil hydrate() nichts zum
// Einhängen findet.
//
// Was hier nicht passiert: dass zweimal gerendert wird. Dafür bräuchte
// es Koordination über Anfragen hinweg, und ein Hook hat keinen
// geteilten Zustand. Das bleibt erstmal so — ein Ansturm kostet
// Rechenzeit, keine Seite.
try {
context.db.create("ssr", {
path: url,
content: tpl,
// @ts-ignore
validUntil: context.ssrCacheValidUntil,
// dependency strings: "col:id" for detail, "col:*" for list (deduplicated)
dependencies: depsKeys,
})
} catch (e) {
utils.log("ssr cache: Eintrag für " + url + " wurde parallel geschrieben, Anfrage ohne eigenen Cache-Eintrag")
}
} }
throw { throw {
+34 -5
View File
@@ -3,9 +3,38 @@ import App from "./App.svelte"
import { hydrate } from "svelte" import { hydrate } from "svelte"
import { setupI18n } from "./lib/i18n/index" import { setupI18n } from "./lib/i18n/index"
// Initialize i18n before mounting the app /**
setupI18n().then(() => { * Ein Fehlschlag beim Start darf nicht stillschweigend bleiben.
let appContainer = document?.getElementById("appContainer") *
hydrate(App, { target: appContainer }) * Ohne i18n gibt es keine Labels, und `hydrate()` kann werfen, wenn das
}) * Server-Markup vom Client abweicht. In beiden Fällen bleibt das
* ausgelieferte HTML stehen: der Besucher sieht eine tote Seite ohne jeden
* Hinweis, und für einen Test ist das nicht unterscheidbar von "noch nicht
* fertig".
*/
function markBootFailed(stage: string, error: unknown) {
console.error(`[app] Start fehlgeschlagen (${stage}):`, error)
document?.getElementById("appContainer")?.setAttribute("data-app-ready", "error")
}
// Initialize i18n before mounting the app
setupI18n()
.then(() => {
let appContainer = document?.getElementById("appContainer")
try {
hydrate(App, { target: appContainer })
} catch (error) {
markBootFailed("hydrate", error)
return
}
// Signal "die App ist hydration gelaufen": erst gesetzt, wenn i18n
// geladen und der erste Render abgeschlossen ist.
//
// Ohne das gibt es keinen zuverlässigen Zeitpunkt, an dem die Seite
// fertig ist. `domcontentloaded` sagt nichts über den Zustand des
// Containers aus, und wer auf "Inhalt vorhanden" wartet, wartet auf
// etwas, das auch nach 15 s nicht kommen muss.
appContainer?.setAttribute("data-app-ready", "true")
})
.catch((error: unknown) => markBootFailed("i18n", error))