Skip to content

perf(prerender): reuse captured RSC bytes via response side-channel#1104

Closed
james-elicx wants to merge 1 commit intomainfrom
alt/prerender-rsc-sidechannel
Closed

perf(prerender): reuse captured RSC bytes via response side-channel#1104
james-elicx wants to merge 1 commit intomainfrom
alt/prerender-rsc-sidechannel

Conversation

@james-elicx
Copy link
Copy Markdown
Collaborator

What this changes

App Router prerender renders each route once instead of twice. The HTML render already accumulates the raw Flight bytes through getRawBuffer() (used today for ISR cache metadata). This surfaces those bytes back to the prerender driver via a response side-channel — mirroring Next.js's metadata.flightData pattern — instead of re-invoking the handler with RSC: 1.

Alternative to #1097, which solves the same problem by parsing the embedded <script> chunks back out of the HTML response. Comparison below.

How

  • Server side (app-page-render.ts): when in prerender mode, append the captured raw RSC bytes to the HTML response body and set x-vinext-rsc-byte-length. Capture is mandatory in prerender mode for any route that reaches the renderer — shouldCaptureRscForPrerender = isPrerender && !isForceDynamic && revalidateSeconds !== 0 covers revalidate: Infinity and force-static, which the existing cache-metadata predicate excluded.
  • Driver side (prerender.ts): read the response as one ArrayBuffer, split at total - x-vinext-rsc-byte-length. No second invocation, no fallback path. A missing header throws — surfaces regressions loudly instead of silently double-rendering.

Comparison vs #1097

#1097 (HTML scrape) this PR (side-channel)
Lines changed +447 / −9 +141 / −21
Render passes 1 1
HTML parsing regex <script> walker + JSON-string tokenizer none
RSC bytes source embedded <script> chunks → JSON.parse → join getRawBuffer() direct
.rsc content fixFlightHints-rewritten Flight (,"stylesheet","style" in HL hints) raw React Flight bytes — matches Next.js's metadata.flightData
New protocols inline-script chunk format with __VINEXT_RSC_DONE__ sentinel one response header
Coupling reader tightly coupled to embed-emitter format reuses existing capturedRscDataRef plumbing
Aligns with Next.js architecture no — scrape after the fact yes — single render, capture-as-side-channel

Note: #1097's claim that "the RSC entry applies the same fix at the source, so extracted and separately requested .rsc output stay equivalent" is incorrect — fixFlightHints exists only in app-ssr-stream.ts:createRscEmbedTransform and is applied only to the HTML embed chunks. Adopting #1097 would change vinext's .rsc output relative to today.

Validation

  • vp check clean on all 3 changed files (formatting, lint, types)
  • vp test run tests/app-page-render.test.ts tests/app-page-cache.test.ts tests/app-page-execution.test.ts tests/app-ssr-stream.test.ts tests/prerender.test.ts120 passed | 10 skipped (only failure is the pre-existing CF Workers build setup error in tests/prerender.test.ts > Cloudflare Workers hybrid build, identical on main)
  • The 4 prerender lifecycle tests in tests/app-page-render.test.ts were updated to assert the new [HTML][RSC] body shape via a readPrerenderBundle helper that mirrors the driver's split logic. They caught the contract change with the right diff at the right layer.

Risks / follow-ups

  • .rsc output changes (raw Flight, no fixFlightHints rewrite). This is alignment with Next.js, but if anything downstream relied on the rewritten form (CDN integrity hashes, snapshot tests of .rsc content), update those.
  • Memory: HTML is buffered in memory in prerender mode. Same as today's response.text() and perf(prerender): reuse embedded RSC payload #1097's HTML scrape — no regression, but documents the constraint.
  • CF Workers prerender path (rscHandler is a fetch(...) to a worker isolate): the wire goes over plain HTTP, headers and body work the same way. Not yet end-to-end-verified because the existing Cloudflare Workers hybrid build integration test fails at the build setup step on both main and this branch (rolldown/cloudflare-plugin issue, unrelated). Worth a manual run before flipping out of draft.
  • Header naming: x-vinext-rsc-byte-length is on the actual HTTP response. The prerender prod-server is bound to 127.0.0.1 and gated by x-vinext-prerender-secret, so it shouldn't leak. Could prefix x-vinext-internal- to make it explicit — bikeshed.
  • Helper testability: appendCapturedRscToHtmlResponse and the driver-side split are file-local. Worth extracting to a shared prerender-bundle.ts with named exports + dedicated unit tests for the encode/decode contract (round-trip, malformed header, RSC length > body length, multi-byte UTF-8 boundary in HTML).
  • TODO removal: today's // TODO: Extract RSC payload from the first response instead of invoking the handler twice. is now obsolete — replaced with the side-channel comment.

Test plan

  • Unit: vp test run tests/app-page-render.test.ts (22 passed, including 4 prerender lifecycle tests asserting new bundle shape)
  • Cache writeback: vp test run tests/app-page-cache.test.ts (28 passed — confirms runtime ISR cache path unaffected)
  • Capture mechanism: vp test run tests/app-ssr-stream.test.ts tests/app-page-execution.test.ts (raw bytes, fused tee, fixFlightHints non-application all verified)
  • Integration: vp test run tests/prerender.test.ts (49 passed for app-basic prerender; CF Workers integration unverified due to pre-existing setup issue)
  • Manual end-to-end CF Workers prerender (vinext build on a real Workers project)
  • Confirm no public client breakage from the .rsc content change (raw vs rewritten Flight)

Refs #563

App Router prerender currently re-invokes the route handler with RSC: 1
to fetch the .rsc payload after rendering the HTML — doubling work per
route. The HTML render already accumulates the raw Flight bytes via
`getRawBuffer()` (used today for ISR cache metadata). Surface that
buffer back to the prerender driver so a single render produces both
halves, mirroring Next.js's `metadata.flightData` side-channel.

The prod-server appends the captured raw RSC bytes to the prerender
HTML response body and emits `x-vinext-rsc-byte-length` so the
driver can split. Capture is mandatory in prerender mode for any
prerenderable route — including `revalidate: Infinity` and force-static,
which the cache-metadata predicate previously excluded. A null
captured ref now throws an invariant error rather than silently
double-rendering.

Output `.rsc` matches Next.js's: raw React Flight bytes, no
fixFlightHints rewrite (which only applies to HTML-embedded chunks).

Refs #563
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 6, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1104

commit: 06458ba

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@james-elicx james-elicx closed this May 6, 2026
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