From 0004bc98a1edb40da81e49fbcdbe9ce2bde0ee2c Mon Sep 17 00:00:00 2001 From: Sebastian Frank Date: Wed, 30 Sep 2026 20:58:10 +0000 Subject: [PATCH] fix(ssr): Cache-Schreibfehler schlucken, App-Start absichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/hooks/ssr/get_read.js | 41 +++++++++++++++++++++++++++++++-------- frontend/src/index.ts | 39 ++++++++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/api/hooks/ssr/get_read.js b/api/hooks/ssr/get_read.js index 8e62592..f97fc1e 100644 --- a/api/hooks/ssr/get_read.js +++ b/api/hooks/ssr/get_read.js @@ -194,14 +194,39 @@ const APP_SERVER_MODULE_PATH = "../lib/app.server" if (cacheIt && !noCache) { // save cache - 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, - }) + // + // `context.db.create` ist ein Insert, kein Upsert. Nach einer + // Invalidierung rendern mehrere Anfragen dieselbe URL gleichzeitig, + // und alle schreiben denselben Eintrag. Der Unique-Index auf `path` + // lässt genau einen gewinnen; die Verlierer bekommen "duplicate + // key". + // + // 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 { diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 2da77a0..0e03226 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -3,9 +3,38 @@ import App from "./App.svelte" import { hydrate } from "svelte" import { setupI18n } from "./lib/i18n/index" -// Initialize i18n before mounting the app -setupI18n().then(() => { - let appContainer = document?.getElementById("appContainer") - hydrate(App, { target: appContainer }) -}) +/** + * Ein Fehlschlag beim Start darf nicht stillschweigend bleiben. + * + * 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)) \ No newline at end of file