Skip to content

perf(prerender): reuse embedded RSC payload#1097

Open
NathanDrake2406 wants to merge 8 commits intocloudflare:mainfrom
NathanDrake2406:nathan/prerender-rsc-dedupe
Open

perf(prerender): reuse embedded RSC payload#1097
NathanDrake2406 wants to merge 8 commits intocloudflare:mainfrom
NathanDrake2406:nathan/prerender-rsc-dedupe

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 6, 2026

What this changes

App Router prerender now reconstructs the canonical .rsc payload from the streamed RSC chunk scripts already embedded in the prerendered HTML response. When extraction succeeds, prerender avoids the previous second RSC: 1 handler invocation.

Why

The prerender pipeline already renders HTML first, and that HTML includes streamed Flight chunks for hydration. Reusing those chunks avoids duplicate App Router work during static output generation.

Approach

  • Parse Vinext's modern streamed RSC chunk protocol: self.__VINEXT_RSC_CHUNKS__...push(<safeJsonStringify(chunk)>) plus self.__VINEXT_RSC_DONE__=true.
  • Preserve the HTML-safe JSON encoding semantics used by createRscEmbedTransform.
  • Fall back to a second RSC: 1 request only when HTML has no RSC chunk scripts at all, which covers middleware/custom HTML responses that bypass the App Router HTML stream.
  • Treat partial or malformed embeds as errors instead of silently falling back.
  • Fail the route when the fallback RSC request returns a non-OK response so prerender never writes an empty or corrupt .rsc artifact.

Validation

  • vp test run tests/prerender.test.ts -t "RSC extraction|middleware short-circuit fallback RSC request fails|falls back to a second RSC"
  • vp check tests/prerender.test.ts packages/vinext/src/build/prerender.ts
  • Commit hook vp check --fix plus knip --no-progress

Notes

  • This extractor intentionally targets the current modern chunk/done protocol; legacy browser bootstrap support is not part of the prerender extractor.
  • tests/app-elements.test.ts was updated to include artifactCompatibility after merging current main so CI matches the current AppElements metadata contract.
  • Full test suite was not run locally.

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@1097

commit: c8a7123

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 6, 2026 07:16
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: perf(prerender): reuse embedded RSC payload

Good optimization. The approach of extracting the RSC payload from the already-rendered HTML avoids a redundant second RSC: 1 handler invocation during prerender. The parser is carefully written, the error handling is strict, and the test coverage is solid.

What looks good

  • Strict-or-fallback design. Recognized markers trigger strict parsing (malformed = throw), while unrecognized HTML falls back gracefully to the old separate-request path. This is the right call — silent corruption of .rsc files would be much worse than a redundant request.
  • safeJsonStringify round-trip safety. The parser uses JSON.parse to reverse the safeJsonStringify encoding, which correctly handles the \u003c/\u003e/\u0026 escapes. The test at line 73 ("</script><script>alert(1)</script>") explicitly covers the XSS breakout case.
  • Integration test with rscRequestCount sentinel. The prerenderApp — RSC extraction test (line 142) uses a mock server that returns 500 on any RSC request, asserting rscRequestCount === 0. This proves the optimization actually avoids the second request.
  • Legacy payload support. Keeping __VINEXT_RSC__ support means existing prerendered content and older build outputs don't break.

Comments below

}

export function extractRscPayloadFromPrerenderedHtml(html: string): ExtractRscPayloadResult {
const scriptPattern = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex /<script(?:\s[^>]*)?>[\s\S]*?<\/script>/gi is safe here because safeJsonStringify escapes all < and > in embedded JSON payloads, so no false </script> can appear inside a script body. Worth noting for future maintainers though — if the emitter (app-ssr-stream.ts) ever stops using safeJsonStringify, this parser would silently truncate chunks. A comment here linking to the safety invariant would be helpful:

Suggested change
const scriptPattern = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
// Safe because safeJsonStringify (used by createRscEmbedTransform) escapes
// all '<' and '>' in embedded JSON, preventing false </script> matches.
const scriptPattern = /<script(?:\s[^>]*)?>[\s\S]*?<\/script>/gi;

Comment thread packages/vinext/src/build/prerender.ts Outdated

function parseEmbeddedJsonString(script: string, start: number): { value: string; next: number } {
let index = start;
while (/\s/.test(script[index] ?? "")) index++;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: calling new RegExp (via /\s/) on every character in a tight loop is not ideal for performance on large HTML documents. For hot-path parsing, a simple space/tab/newline check would be faster and clearer:

Suggested change
while (/\s/.test(script[index] ?? "")) index++;
while (isWhitespace(script[index] ?? "")) index++;

with a helper like:

function isWhitespace(ch: string): boolean {
  return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
}

Not blocking — the script contents are typically short, so this is a micro-optimization. But since this runs during build for every prerendered page, it adds up.

Comment thread packages/vinext/src/build/prerender.ts Outdated

if (trimmedScript.startsWith(RSC_CHUNK_SCRIPT_PREFIX)) {
sawRscMarker = true;
const markerIndex = trimmedScript.indexOf(RSC_CHUNK_PUSH_MARKER);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indexOf search for RSC_CHUNK_PUSH_MARKER ("__VINEXT_RSC_CHUNKS__.push(") scans the full trimmed script content from position 0, but we already know the script starts with RSC_CHUNK_SCRIPT_PREFIX. Searching from the prefix length would be slightly more precise:

Suggested change
const markerIndex = trimmedScript.indexOf(RSC_CHUNK_PUSH_MARKER);
const markerIndex = trimmedScript.indexOf(RSC_CHUNK_PUSH_MARKER, RSC_CHUNK_SCRIPT_PREFIX.length);

This also guards against a hypothetical edge case where the push marker text appears inside the prefix itself (currently impossible, but defensive).

Comment thread packages/vinext/src/build/prerender.ts Outdated
}

function extractLegacyRscPayload(script: string): string | null {
const trimmedScript = script.trim().replace(/;$/, "");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.parse can throw on malformed input. Currently, a parse error here would bubble as an unhandled exception with a generic message. Consider wrapping it to provide the same clear [vinext] Malformed legacy prerender RSC embed: prefix:

let parsed: unknown;
try {
  parsed = JSON.parse(jsonSource);
} catch {
  throw new Error("[vinext] Malformed legacy prerender RSC embed: invalid JSON");
}

Same applies to the JSON.parse in parseEmbeddedJsonString at line 211.

Comment thread tests/prerender.test.ts
});

describe("prerenderApp — RSC extraction", () => {
it("writes the .rsc file from rendered HTML without a second RSC request", async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great test — using a mock server that fails RSC requests is a clean way to prove the optimization works.

One gap: there's no test for the fallback path where the HTML has no RSC markers and the separate RSC request is actually made. Consider adding a companion test where the mock server returns plain HTML (no __VINEXT_RSC_CHUNKS__ markers), and verifying that rscRequestCount === 1 and the .rsc file is still written correctly. This would ensure the fallback path doesn't regress.

Comment thread packages/vinext/src/build/prerender.ts Outdated

while ((match = scriptPattern.exec(html)) !== null) {
const script = match[1] ?? "";
const trimmedScript = script.trim().replace(/;$/, "");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trimmedScript strips a trailing semicolon with .replace(/;$/, ""), but the done marker emitted by createInlineScriptTag is self.__VINEXT_RSC_DONE__=true (no trailing semicolon). This works fine. However, note that trimmedScript is also used for the chunk scripts, but the chunk content can end with ; (from the emitter). The assertOnlyTrailingSemicolon function handles that separately, so the .replace(/;$/, "") on line 272 could cause a subtle issue if a chunk script only consists of RSC_CHUNK_SCRIPT_PREFIX followed by the push call followed by ; — the semicolon would be stripped before the startsWith check on line 288, which is actually correct. Just wanted to trace through this to confirm the logic is sound — it is.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted successfully. Here's a summary of the feedback:

Overall: Solid optimization with good test coverage. The strict-or-fallback error handling approach is well-designed.

Key comments:

  1. Safety invariant comment — The script tag regex is safe because safeJsonStringify escapes </>, but a comment documenting this invariant would help future maintainers who might change the emitter.

  2. Micro-optimization — The /\s/ regex test on every character in parseEmbeddedJsonString could be replaced with a simple char comparison for better build-time performance across many prerendered pages.

  3. indexOf start offset — The push marker search could start from the prefix length instead of position 0 for precision.

  4. Error wrapping for JSON.parse — Both extractLegacyRscPayload and parseEmbeddedJsonString call JSON.parse without catching parse errors, which would produce generic error messages instead of the clear [vinext] Malformed... prefix used elsewhere.

  5. Missing fallback path test — The integration test proves the optimization works, but there's no test verifying the fallback path (HTML with no RSC markers correctly triggers the separate RSC request and still writes the .rsc file).

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Bonk: the test change in tests/app-elements.test.ts is intentional for CI. After rebasing/merging current main, AppElements metadata now includes the artifactCompatibility envelope from #1062, so the legacy-compatible payload round-trip expectation needs to include that field. This is not changing runtime behavior; it just updates the stale test fixture to match the current metadata contract.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: perf(prerender): reuse embedded RSC payload

Good optimization — the approach is sound and the implementation is careful. The strict-or-fallback error model is well-designed, the parser is tightly scoped to the known emitter protocol, and the test coverage is strong (both unit and integration). The previous review's suggestions around isWhitespace, indexOf offset, and JSON.parse wrapping have all been addressed in the current diff.

Two substantive items below, plus a few smaller observations.

Key concern: fixFlightHints semantic difference

The embedded RSC chunks have fixFlightHints applied (app-ssr-stream.ts:49 — rewrites as="stylesheet"as="style" in HL hint lines), while the raw RSC stream stored via getRawBuffer() does not. The old code path fetched RSC data via a separate RSC: 1 request, which would go through the full RSC entry — the comment at app-ssr-stream.ts:46-48 says the RSC entry also fixes HL hints at the source, so both paths should produce equivalent output.

However, this equivalence depends on the RSC entry always applying fixFlightHints at the source. If that ever changes, the extracted payload would have the fix applied while a fresh RSC request would not (or vice versa). This is not a bug today, but it's a coupling worth documenting — a brief comment near the extraction call site noting that the embedded chunks include fixFlightHints transforms would help future maintainers.

app-elements.test.ts change

The author's explanation in the PR comments is correct — this is a test fixture update to match the artifactCompatibility envelope from #1062. The field is already used in ~20 other places in the same test file. Not a concern.

Comment thread packages/vinext/src/build/prerender.ts Outdated
continue;
}

if (legacyPayload === null) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed protocols: legacy detection should be order-independent

The guard if (legacyPayload === null) means that if a page somehow contains both a legacy __VINEXT_RSC__ embed and modern chunk scripts, the legacy payload takes precedence (sets sawRscMarker = true early). The modern chunks would still be collected into chunks[] via the startsWith(RSC_CHUNK_SCRIPT_PREFIX) block below, and since chunks.length > 0 is checked first at line 326, the modern chunks would actually win in the return value.

This is accidentally correct — modern chunks win when both are present. But the intent isn't obvious. Consider adding a brief comment like // Legacy check runs once; modern chunks take priority if both are present.

Comment thread packages/vinext/src/build/prerender.ts Outdated
return { status: "ok", payload: legacyPayload };
}

if (sawRscMarker) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreachable state?

This branch fires when sawRscMarker is true but chunks is empty and legacyPayload is null. The only way sawRscMarker can be true without producing chunks or a legacy payload is if:

  1. A __VINEXT_RSC_DONE__ script was found with zero chunk scripts, OR
  2. A script matched the RSC_CHUNK_SCRIPT_PREFIX but had no push marker (which already throws at line 311).

So this only catches case 1: a done marker with no data scripts. That's a valid malformed state. The error message is slightly misleading though — "no RSC chunks were found" could be read as "we didn't see any embed markers at all" rather than "we found the protocol but it was empty." Consider:

Suggested change
if (sawRscMarker) {
throw new Error("[vinext] Malformed prerender RSC embed: RSC protocol markers present but no data chunks found");

Comment thread packages/vinext/src/build/prerender.ts Outdated
rscHandler(rscRequest),
);
const rscData = rscRes.ok ? await rscRes.text() : null;
const extractedRsc = extractRscPayloadFromPrerenderedHtml(html);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting (for future maintainers) that the payload extracted here includes fixFlightHints transforms from the emitter (app-ssr-stream.ts:49), which rewrites as="stylesheet"as="style" in HL hint lines. The old separate-request path produced equivalent output because the RSC entry also applies the fix at the source. If the emitter or RSC entry ever diverge on this, the .rsc files would differ depending on which path was taken.

A comment would be helpful:

Suggested change
const extractedRsc = extractRscPayloadFromPrerenderedHtml(html);
// NOTE: The embedded RSC chunks include fixFlightHints transforms
// (applied by createRscEmbedTransform in app-ssr-stream.ts). The RSC
// entry applies the same fix at the source, so both paths produce
// equivalent .rsc output.
const extractedRsc = extractRscPayloadFromPrerenderedHtml(html);

Comment thread tests/prerender.test.ts
const chunks = [
'0:D{"name":"layout"}\n',
'1:["$","div",null,{"children":"hello ) world"}]\n',
'2:["$","span",null,{"children":"</script><script>alert(1)</script>"}]\n',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice — this chunk explicitly covers the </script> breakout vector and the ) character that could confuse the push-call parser. Good defensive test case.

Comment thread tests/prerender.test.ts Outdated
}
});

it("falls back to a separate RSC request when rendered HTML has no RSC markers", async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — this addresses the previous review's gap about missing fallback path coverage. The mock server returns plain HTML (no RSC markers), verifies rscRequestCount === 1, and confirms the .rsc file is still written correctly.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary of what I found:

Overall: Solid optimization, well-implemented. The parser is tightly scoped to the emitter protocol, error handling is strict, and both the previous review's feedback (isWhitespace helper, indexOf offset, JSON.parse wrapping) and the missing fallback test have been addressed.

Key comments:

  1. fixFlightHints coupling — The extracted RSC chunks include fixFlightHints transforms from the emitter, while the old separate-request path got them from the RSC entry. Both produce equivalent output today, but this coupling should be documented at the extraction call site (prerender.ts:1244).

  2. Mixed protocol precedence — When both legacy __VINEXT_RSC__ and modern chunk scripts exist in the same HTML, modern chunks silently win. This is accidentally correct but the intent isn't obvious — suggested adding a comment.

  3. Error message clarity — The "no RSC chunks were found" error at line 337 fires when protocol markers are present but empty. Suggested rewording to distinguish from "no markers at all."

  4. Positive notes — The XSS breakout test () and </script> in chunk data), the rscRequestCount sentinel pattern, and the new fallback path integration test are all well done.

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: perf(prerender): reuse embedded RSC payload

This is a well-executed optimization. Two prior reviews have already flagged the substantive issues and the author has addressed them — JSON.parse wrapping, isWhitespace helper, indexOf offset, fallback path test. The code as-is is clean and correct.

What looks good

  • Parser is tightly coupled to the emitter by design. extractRscPayloadFromPrerenderedHtml mirrors the exact protocol from createRscEmbedTransform in app-ssr-stream.ts. The string constants (RSC_CHUNK_PUSH_MARKER, RSC_CHUNK_SCRIPT_PREFIX, RSC_DONE_MARKER) match the emitter's output character-for-character. This is fragile in the abstract, but the right call here — the parser only needs to handle vinext's own output, and the tests will break if either side changes.

  • Strict-or-fallback error model is correct. Recognized markers trigger strict parsing (malformed = throw), unrecognized HTML falls through to the old separate-request path. This prevents silent .rsc file corruption, which would be far worse than a redundant request.

  • Test coverage is thorough. Unit tests cover: happy path, missing done marker, marker text inside payload data, trailing code injection, invalid JSON, legacy format, empty markers, and the XSS breakout vector (</script> in chunk data). Integration tests prove both the optimization path (rscRequestCount === 0) and the fallback path (rscRequestCount === 1).

  • fixFlightHints equivalence is documented. The comment at line 1248 correctly explains why the extracted payload (which includes fixFlightHints from the emitter) matches what the separate RSC request would return (RSC entry applies the same fix at source). The rewrite is idempotent, so this is safe even if both paths change independently.

  • app-elements.test.ts change is correct. Just a stale test fixture update to include artifactCompatibility from #1062.

One minor nit below, not blocking.

Comment thread packages/vinext/src/build/prerender.ts Outdated
} catch {
throw new Error("[vinext] Malformed prerender RSC embed: invalid chunk JSON");
}
if (typeof parsed !== "string") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the typeof parsed !== "string" check after JSON.parse on a "..."-delimited slice is technically unreachable — JSON.parse('"hello"') always produces a string. The only way to get a non-string would be if the manual quote-matching logic above found a false closing quote, but that can't happen because the escape tracking is correct.

Not suggesting you remove it — defensive checks in parsers are fine — just noting it's dead code in practice. If you wanted to be explicit about this, a comment like // Defensive: should always be a string given the quote-delimited extraction above would clarify intent.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted successfully. Here's a summary:

Approved. The optimization is well-implemented and the previous two review rounds have already driven the important fixes (JSON.parse wrapping, isWhitespace helper, indexOf offset, fallback path test coverage). The code is clean and correct.

Key observations:

  • Parser is tightly coupled to the emitter protocol by design — the string constants match createRscEmbedTransform character-for-character, and tests will catch any drift
  • Strict-or-fallback error model prevents silent .rsc file corruption
  • Test coverage is thorough: 8 unit tests covering happy path, edge cases, and the XSS breakout vector, plus 2 integration tests proving both the optimization path and fallback path
  • fixFlightHints equivalence between the two code paths is correctly documented
  • app-elements.test.ts change is a straightforward fixture update for feat(app-router): add artifact compatibility metadata #1062

One minor nit: the typeof parsed !== "string" check at prerender.ts:221 is technically unreachable given the quote-delimited extraction above, but defensive checks in parsers are fine — just noted it for clarity.

github run

Cleanups on top of cloudflare#1097:

1. Drop the legacy `__VINEXT_RSC__` extractor. The browser entry still
   recognizes that format for in-the-wild cached HTML, but prerender
   parses HTML it just emitted in this build, where the writer always
   emits the chunked form. The legacy branch is unreachable here.

2. Remove the silent fallback to a second `RSC: 1` invocation. The
   writer is in-tree, so a missing-marker case is a vinext-internal
   regression we want to surface loudly rather than mask by
   double-rendering. `extractRscPayloadFromPrerenderedHtml` now
   returns `string` and throws on any anomaly.

3. Replace the manual JSON-string tokenizer (parseEmbeddedJsonString +
   assertOnlyTrailingSemicolon + isWhitespace, ~80 lines) with a
   ~12-line slice + JSON.parse. Relies on the same safeJsonStringify
   invariant the original parser relied on; tampering with trailing
   code now surfaces as 'invalid chunk JSON' instead of a separate
   diagnostic, which is fine for an internal protocol.

4. Remove the obsolete TODO marker ("Extract RSC payload from the
   first response instead of invoking the handler twice") — the PR
   resolves it.

7. Tighten the chunk-presence assertion: empty HTML or HTML with only
   the done marker now throw 'no chunk scripts found in HTML' instead
   of returning the fallback sentinel.

Test updates mirror these:
- Drop the two legacy-format tests.
- Drop the "falls back to a separate RSC request" integration test
  (the fallback no longer exists).
- Update error-message assertions where the diagnostic text changed.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

UnknownError: "Overloaded"

github run

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

UnknownError: "Overloaded"

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

UnknownError: "Overloaded"

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

UnknownError: "Overloaded"

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex review

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

UnknownError: "Overloaded"

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

const rscData = extractRscPayloadFromPrerenderedHtml(html);

P1 Badge Restore fallback when HTML lacks embedded RSC chunks

Routes that short-circuit in middleware by returning a direct HTML Response (a supported Next.js pattern) will not contain __VINEXT_RSC_CHUNKS__ markers. Calling extractRscPayloadFromPrerenderedHtml(html) unconditionally now turns those otherwise-renderable pages into prerender errors (no chunk scripts found) instead of preserving the old behavior of still writing HTML and only treating .rsc as optional. Add a compatibility fallback path when no Vinext RSC embed markers are detected.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: the optimization is correct and well-implemented.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Re-review: perf(prerender): reuse embedded RSC payload

Looked through the implementation end-to-end (emitter coupling, error handling paths, call site context, test coverage). The optimization is correct and well-implemented. Prior review rounds have driven the important fixes. Two items worth noting:

1. Regex matches all script tags, not just RSC embed scripts (prerender.ts:200)

extractRscPayloadFromPrerenderedHtml iterates every <script> in the full HTML document — React Fizz output, user inline scripts, bootstrap scripts, etc. Non-RSC scripts are silently skipped via the startsWith check, so correctness is fine for realistic inputs.

However, a user inline script that happens to start with the exact RSC_CHUNK_SCRIPT_PREFIX string (self.__VINEXT_RSC_CHUNKS__=self.__VINEXT_RSC_CHUNKS__||[];) would be parsed as an RSC chunk, potentially corrupting the .rsc file. Unlikely in practice but not impossible (copy-pasted debug code, analytics snippets that reference the global, etc.).

A tighter regex anchoring on the known prefix would be more robust and slightly faster for pages with many non-RSC scripts. Not blocking.

2. PR description is stale (prerender.ts:219)

The PR description says:

Support the legacy self.__VINEXT_RSC__={ rsc: [...] } embedded payload shape.
Keep an explicit compatibility fallback to the separate RSC request only when no recognized Vinext RSC embed markers exist.

Neither of these exists in the code. There is no legacy __VINEXT_RSC__ extraction, and there is no fallback to a separate RSC request — the function unconditionally throws when no chunks are found. The approach was simplified to strict-only (which is the right call since the emitter is in-tree). The description should be updated to match the actual implementation.

What looks correct

  • Error handling at call site: extractRscPayloadFromPrerenderedHtml throws on any anomaly, caught by renderUrl's try/catch (line 1195), which converts to { status: "error" } or { status: "skipped" }. Malformed embeds degrade gracefully — they don't crash the build.
  • Route filtering: API routes and route handlers are excluded before renderUrl (lines 912-931), so the extractor only runs on page routes that go through SSR with createRscEmbedTransform.
  • Parser correctness: endsWith(")") + JSON.parse validates the exact expected shape and rejects trailing code injection. The safeJsonStringify round-trip correctly reverses \u003c/\u003e/\u0026 escapes.
  • Nonce handling: The regex /<script(?:\s[^>]*)?>... correctly matches <script nonce="..."> output from createInlineScriptTag.
  • fixFlightHints equivalence: The embedded chunks have fixFlightHints applied (idempotent rewrite), and the RSC entry applies the same fix at source. Both paths produce equivalent .rsc output. The comment at line 1149-1153 documents this correctly.
  • Test coverage is thorough: Happy path, missing done marker, marker text inside payload, trailing code injection, invalid JSON, XSS breakout vector, empty markers, plus integration tests proving both the optimization path (rscRequestCount === 0) and the error behavior.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary:

Overall: The optimization is correct and well-implemented. Two items worth noting:

  1. Regex matches all script tags (prerender.ts:200) — The extractor iterates every <script> in the HTML, not just RSC embed scripts. Non-RSC scripts are silently skipped, but a user inline script that starts with the exact RSC_CHUNK_SCRIPT_PREFIX would be parsed as an RSC chunk and could corrupt the .rsc file. Unlikely in practice, but a tighter regex would be more robust.

  2. PR description is stale — The description mentions legacy __VINEXT_RSC__ support and a fallback to the separate RSC request, but neither exists in the code. The function unconditionally throws when no chunks are found (which is the right call). The description should be updated.

Everything else checks out: error handling at the call site is correct, route filtering excludes non-page routes, the parser validates the exact shape, nonce handling works, fixFlightHints equivalence is documented, and test coverage is thorough.

github run

…sponses

The previous cleanup made `extractRscPayloadFromPrerenderedHtml` throw
on any HTML lacking RSC embed chunks. That's wrong for one legitimate
case: middleware that short-circuits the App Router pipeline with a
custom 200 HTML response — that HTML never went through
createRscEmbedTransform, so it has no chunks and no done marker.

Distinguish two cases at the extractor level:
- No chunks AND no done marker → middleware short-circuit. Return null
  so the caller falls back to a second invocation with `RSC: 1`.
- Done marker present without chunks → real bug, throw.
- Chunks present without done marker → real bug, throw.
- Malformed/tampered chunk JSON → real bug, throw.

The driver checks for null and recovers by issuing a second handler
invocation with RSC headers, writing whatever that returns as the
.rsc file.

Adds an integration test alongside the existing dedup test:
- Middleware short-circuit: server returns 200 HTML without chunks for
  page requests, valid Flight for RSC: 1 → driver writes the fallback
  payload as .rsc, with exactly one of each request kind.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test body-only review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review: perf(prerender): reuse embedded RSC payload

The optimization is well-motivated — extracting the RSC payload from the already-rendered HTML avoids a redundant second handler invocation during prerender. The parser is carefully written, error handling is strict, and test coverage is solid. One behavioral change and a few small items worth discussing.


1. Behavioral change: empty .rsc files on fallback failure (prerender.ts:1178)

The old code:

const rscData = rscRes.ok ? await rscRes.text() : null;
// ...
if (rscData !== null) {
  // write .rsc file
}

The new code:

rscData = rscRes.ok ? await rscRes.text() : "";
// ...
// always writes .rsc file (no null guard)

This changes observable behavior: previously, a non-OK fallback RSC response resulted in no .rsc file on disk. Now it writes an empty .rsc file. An empty file served as text/x-component during client-side navigation will likely break hydration, which is worse than the old behavior (404 -> full page load).

Suggestion: Restore the null sentinel for failed fallback RSC requests and guard the .rsc write with if (rscData !== null) as before:

rscData = rscRes.ok ? await rscRes.text() : null;

And at the write site (prerender.ts:1190-1195):

if (rscData !== null) {
  const rscOutputPath = getRscOutputPath(urlPath);
  const rscFullPath = path.join(outDir, rscOutputPath);
  fs.mkdirSync(path.dirname(rscFullPath), { recursive: true });
  fs.writeFileSync(rscFullPath, rscData, "utf-8");
  outputFiles.push(rscOutputPath);
}

2. Line reference will drift (prerender.ts:184-185)

The comment says "Keep this in sync with the writer at packages/vinext/src/server/app-ssr-stream.ts:73". Line 73 will drift as the file is edited. Consider referencing the function/method name instead:

// Keep this in sync with the flush() method in createRscEmbedTransform
// (packages/vinext/src/server/app-ssr-stream.ts).

3. Trailing semicolon strip deserves a comment (prerender.ts:210)

The .replace(/;$/, "") on every script body is correct — it makes the parser resilient if createInlineScriptTag ever appends a semicolon. But it's non-obvious why it's there. A brief comment would help:

const script = (match[1] ?? "").trim().replace(/;$/, ""); // strip optional trailing semicolon from createInlineScriptTag

4. PR description drift

The PR description still mentions:

  • "Support the legacy self.__VINEXT_RSC__={ rsc: [...] } embedded payload shape"
  • "Keep an explicit compatibility fallback to the separate RSC request only when no recognized Vinext RSC embed markers exist"

Neither of these exists in the code. The simplification to modern-only extraction is the right call, but the description should be updated to match.


What looks good

  • Strict error model. Recognized markers trigger strict parsing; unrecognized HTML returns null for fallback. Partial/malformed embeds throw rather than silently corrupting .rsc files.
  • safeJsonStringify round-trip correctness. The regex safety invariant is documented in the JSDoc. The </script> breakout test (prerender.test.ts:73) covers the XSS vector.
  • fixFlightHints equivalence documented. The comment at the call site (prerender.ts:1160-1164) correctly explains why both paths produce equivalent output.
  • Integration tests. Both the optimization path (rscRequestCount === 0) and the fallback path (rscRequestCount === 1) are tested with mock servers. The fallback test addresses the gap flagged in the first review round.
  • app-elements.test.ts change. Fixture update for artifactCompatibility from feat(app-router): add artifact compatibility metadata #1062 — straightforward and correct.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted successfully. Here's a summary of the feedback:

Overall: Good optimization with solid test coverage. The parser is carefully written and the strict-or-fallback error model is well-designed. One substantive issue found:

  1. Empty .rsc files on fallback failure (prerender.ts:1178) — The old code skipped writing the .rsc file when the RSC request failed (null sentinel + guard). The new code unconditionally writes it, producing an empty file when the fallback RSC request returns non-OK. An empty text/x-component file would break client-side navigation hydration. Suggested restoring the old null guard.

  2. Line reference drift (prerender.ts:184) — Hardcoded :73 line reference to app-ssr-stream.ts will go stale. Suggested referencing the function name instead.

  3. Trailing semicolon strip comment (prerender.ts:210) — The .replace(/;$/, "") is correct but non-obvious; suggested adding a brief comment.

  4. PR description stale — Mentions legacy __VINEXT_RSC__ support and explicit fallback that don't exist in the code.

github run

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.

2 participants