diff --git a/api/hooks/ssr/get_read.js b/api/hooks/ssr/get_read.js index 8e625921..f97fc1e0 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 2da77a06..0e032268 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