Skip to content

fix(docs): serialize prerender so large pages aren't truncated#44

Merged
lukaisailovic merged 1 commit into
mainfrom
fix/docs-prerender-truncation
Jun 26, 2026
Merged

fix(docs): serialize prerender so large pages aren't truncated#44
lukaisailovic merged 1 commit into
mainfrom
fix/docs-prerender-truncation

Conversation

@lukaisailovic

Copy link
Copy Markdown
Owner

What

Set the docs SPA prerender to concurrency: 1, and add a postbuild guard that fails the build if any prerendered page is truncated.

The bug

Visiting a docs page over ~64KiB directly (cold load, e.g. https://openislands.sh/islands/charts/#divergencebars) shows the static HTML but nothing hydrates — charts are empty, the mobile sidebar drawer won't open, links full-reload. It only reproduces on a direct visit (SPA navigation from another page renders client-side and never re-fetches), and it's most visible on mobile because the sidebar is a JS drawer; on desktop the sidebar is CSS-visible so the page looks fine.

Root cause

The TanStack Start SPA prerender defaults to os.cpus().length concurrency (@tanstack/start-plugin-coreprerender.js):

const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length;
const queue = new Queue({ concurrency });
// ...
const html = await res.text();
await promises.writeFile(filepath, html);

Several routes race through the in-process render server at once. Under that load a large response's body stream is cut at the first ~64KiB chunk before res.text() drains it, so any page over 64KiB is written truncated — losing the trailing hydration <script> at the end of <body> — and ships as dead, non-interactive HTML.

Which page loses the race varies per build, and high-core CD runners make it more likely, so it slipped past local builds into production. Evidence:

  • Live /islands/charts/ served exactly 65,536 bytes, no </html>, no hydration bootstrap; /reference/manifest/ too.
  • A 773KB JS asset and a 295KB .txt both serve whole in prod → no Cloudflare/serving size limit.
  • A local build truncated a different large page (reference/manifest) while charts was fine — non-deterministic, the race signature.

Fix

  • vite.config.tsprerender: { concurrency: 1 } removes the race. Serial prerender of 21 pages is a negligible build-time cost.
  • scripts/postbuild.mjs — guard that fails the build if any prerendered index.html is missing its closing </html>, so a truncated page can never reach a deploy again (it reached prod silently and was caught by a user, not CI).

Verification

Before: local build → 1 truncated page. After: pnpm build21/21 pages complete, 0 truncated; all 13 pages over 64KiB (manifest 194KB, charts 143KB, …) end with </html> and keep their entry script. Lint + typecheck pass.

The live site stays broken until the next deploy rebuilds with this fix — there's no source change that repairs an already-uploaded truncated asset.

The SPA prerender defaults to os.cpus().length concurrency. Several routes
race through the in-process render server at once, and under that load a large
response body stream is cut at the first ~64KiB chunk before res.text() drains
it. Any page over 64KiB (e.g. /islands/charts, /reference/manifest) is then
written truncated, losing its trailing hydration <script> and shipping as dead,
non-interactive HTML. Which page loses the race varies per build and worsens on
high-core CD runners, so it slipped past local builds into production.

Set prerender.concurrency to 1 to remove the race, and add a postbuild guard
that fails the build if any prerendered page is missing its closing </html>.
@lukaisailovic lukaisailovic merged commit 8230b13 into main Jun 26, 2026
2 checks passed
@lukaisailovic lukaisailovic deleted the fix/docs-prerender-truncation branch June 26, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant