1,145 words
Status: accepted · 8 Sep 2026
Supersedes the document and boot ownership in ADR-0011 and the client-fetched initial page in ADR-0016. Camera precedence, one persistent canvas, and the documentation build pipeline remain in force.
Context
A documentation page must carry its article, navigation and page identity in the first HTML response. Waiting for a catalog or a graphics device makes a reading task depend on a simulation it does not need. A client-only island inside an Astro document cannot satisfy that requirement: the framework can render the document while leaving its useful contents empty.
The game has a different constraint. Navigation must preserve its engine, workers, renderer, warm-up state and current world. Reconstructing that runtime for each document would turn a link into another game startup.
Decision
Astro pre-renders a complete React page shell; one persistent client runtime adds the scene after hydration.
Astro 7 owns route files, the HTML document and generated sitemap. Every
published docs route comes from the existing documentation manifest.
The same Markdown, TypeDoc and Shiki pipeline supplies article HTML to the
server and to subsequent client navigation. Root receives the initial URL
and documentation through props. Its content belongs to that render, never to
a mutable module-global current page.
Production builds from main pre-render every documentation page. Other
branches pre-render prose and one API loading shell; API articles load through
the existing JSON requests. TypeDoc conversion, link validation and all API
JSON assets remain part of every build. IR_PRERENDER_API=1 selects full HTML
for production verification on another branch; 0 selects asynchronous API
pages. Workers Builds and GitHub source-branch variables take precedence over
the local branch, so a pull request targeting main remains a preview build.
The manifest records this choice once for Astro and the hosting rules. Exact
static-asset proxies send known API routes to /docs/api while retaining the
requested URL; aliases redirect to their canonical spelling first. Unknown
paths retain a 404. With no initial manifest or article, the loading shell
renders identical markup at every API address and hydrates before fetching the
requested page. Development serves those shells on demand. This needs no
request-rendering adapter or Worker invocation. The build rejects a routing
table above Cloudflare's static redirect limit instead of silently dropping
API paths.
API exports whose paths differ only in case receive stable suffixes derived from their exact original paths. Case-insensitive filesystems must not let one exported symbol overwrite another's document. Generated links use the canonical paths; the manifest and hosting redirects retain the old addresses.
Root renders PageShell on the server and hydrates it with client:load.
Home, docs, navigation and dialogs are visible without JavaScript. Game modes
render an admission message and navigation while their live controls await
the runtime. Preferences and media queries expose server defaults during
hydration, then expose the browser's settings without writing those defaults
over stored values.
React Router continues to handle navigation after hydration. There is no
second client router and no document swap around the canvas. GameLoader
loads browser services, then starts the App import and catalog request
concurrently. Direct interactive visits also preload their selected mode during
hydration, sharing the same import promise with the lazy route. App remains
outside every route and owns the engine, canvas,
frame loop and renderer warm-up. A contained runtime failure leaves the
reading shell mounted. The home/docs boot cover stays behind readable content;
game modes retain their first-light admission cover.
Renderer initialization failures, device loss, failed render pipelines and frame exceptions publish a terminal runtime failure. The shell replaces the interactive view with a graphics notice and retains reading routes. WebGPU is preferred; the WebGL fallback requires floating-point scene targets. GPU error observers belong to their device so a late error from a retired renderer cannot stop its replacement.
A tab-scoped session-storage marker is set before the runtime import and
cleared on an orderly pagehide. It counts consecutive unclean ends: a
replacement document that finds it takes one strike, and the second strike
in a row leaves graphics stopped until an explicit retry. A session that has
run for a minute resets the count, because a document that drew for that long
and then vanished was discarded, slept or killed rather than crashed on boot.
This bounds browser crash/reload loops without persisting a hardware ban and
without refusing the routine reload of a hung tab. A browser that discards
session storage during a crash cannot provide this evidence; storage denial
does not itself reject graphics. The marker records interrupted sessions,
not proof of their cause.
site.ts supplies route metadata. documentHead.ts renders the shared head,
including the route's canonical address and social tags. Unknown paths return 404. The Worker continues to serve pre-rendered assets without an invocation;
its API, websocket and media routing stays in the adapter. Request-time SSR
can use the same shell with request-scoped props when a route needs it; this
change adds no request-rendering adapter or server-side game instance.
The service worker caches navigation HTML by document address. Cached HTML must describe the requested page, because hydrating another page's markup would discard the server-rendered content. An uncached offline document gets an explicit offline response. Hashed game assets use the current and previous build caches, promoting only requested files into the current build. Startup resource reporting covers assets loaded before service-worker control. The hosting policy defines retention, storage failures and the two production origins.
Alternatives considered
Two client-only islands for chrome and scene. This separates mount points but supplies no server-rendered chrome. A static import from chrome into the engine can still pull graphics code into its startup dependency graph.
An Astro client router with a persisted canvas island. This gives Astro ownership of soft navigation, but requires replacing the established overlay background, query and history behavior. Keeping React Router preserves those semantics and avoids introducing another renderer persistence mechanism.
A second copy of public content below a client-only app. Hiding one tree and copying its HTML into another creates a second content lifetime and can remove the article before its replacement exists. Hydration keeps one shell.
Render every request in the Worker. Static public content needs no request data. Pre-rendering supplies the complete HTML while retaining free static asset delivery; a future dynamic route can opt into request rendering.
Consequences
Production public content remains readable when scripting or graphics fails. Preview API pages require JavaScript; their no-script notice links to the pre-rendered documentation. The engine keeps its existing frame path, and neither document navigation nor hydration adds a renderer. Astro templates and the emitted site need verification in addition to the existing TypeScript and runtime tests.
The docs HTML carries its initial navigation model and article props as well as rendered markup. That makes direct docs responses larger, while avoiding an initial manifest-and-article fetch sequence. Later navigation uses the existing JSON cache. Search and Mermaid remain client enhancements.