Skip to content

NO MERGE: Replacement for #2835 (reapply RSC payload embedding optimization)#2879

Open
justin808 wants to merge 3 commits intomainfrom
jg-codex/no-merge-replacement-2835-main
Open

NO MERGE: Replacement for #2835 (reapply RSC payload embedding optimization)#2879
justin808 wants to merge 3 commits intomainfrom
jg-codex/no-merge-replacement-2835-main

Conversation

@justin808
Copy link
Copy Markdown
Member

@justin808 justin808 commented Mar 28, 2026

Summary

Important

  • NO MERGE until explicitly approved.

Commit


Note

Medium Risk
Changes how RSC Flight data is streamed and embedded into HTML by injecting raw JSON as executable JS expressions and adding NDJSON line buffering/validation; mistakes here could break hydration or introduce XSS-like issues if assumptions about JSON safety are violated.

Overview
Reduces React Server Components (Pro) HTML-stream payload size by eliminating a second JSON.stringify when embedding RSC Flight chunks; injectRSCPayload now pushes each NDJSON object directly into window.REACT_ON_RAILS_RSC_PAYLOADS as a JS expression (with JSON.parse validation) instead of a quoted string.

Updates client hydration to consume preloaded payloads as parsed RSCPayloadChunk objects (no JSON.parse step), adds robust NDJSON buffering for split JSON boundaries and multibyte UTF-8 splits, and centralizes console-log replay logic via a new replayConsoleLog utility. Tests are updated/expanded to cover boundary-splitting, CRLF normalization, malformed NDJSON erroring, and the new .push({ ... }) embedding format.

Reviewed by Cursor Bugbot for commit 50ff949. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Performance
    • Reduced RSC payload size by ~38KB (~24% reduction) for typical product search pages via more efficient embedding (Pro feature)
  • Bug Fixes / Reliability
    • Improved streaming robustness and error handling for server component payloads, with more reliable progressive loading and console-log replay during client hydration

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 28, 2026

Walkthrough

This PR embeds already-serialized NDJSON RSC lines directly into inline <script> .push() calls (avoiding double JSON.stringify), changes preloaded hydration payloads from string arrays to RSCPayloadChunk arrays, enhances streaming buffering/validation, and delegates console-log replay to a new utility with CSP nonce support.

Changes

Cohort / File(s) Summary
Changelog
CHANGELOG.md
Added Unreleased → Performance entry documenting removed double-serialization and estimated ~38KB (~24%) Flight payload savings.
RSC injection & NDJSON streaming
packages/react-on-rails-pro/src/injectRSCPayload.ts
Treat incoming NDJSON lines as pre-serialized JSON, validate with JSON.parse, emit push(${jsonLine}) (no re-stringify), throw on malformed lines, buffer partial lines across stream chunks, and flush remaining buffered data at end-of-stream.
Client hydration / preloaded payloads
packages/react-on-rails-pro/src/getReactServerComponent.client.ts
Change window.REACT_ON_RAILS_RSC_PAYLOADS entries from string[] to RSCPayloadChunk[]; replace createRSCStreamFromArray with createRSCStreamFromPreloadedPayloads that encodes per-chunk html into Uint8Array, replays per-chunk console logs via replayConsoleLog, and adds a closed flag with auto-close on DOM readiness.
Stream transform & console replay flow
packages/react-on-rails-pro/src/transformRSCStreamAndReplayConsoleLogs.ts
Use incremental decoder, add parseAndHandleLines to parse NDJSON lines into RSCPayloadChunk objects, delegate console log execution to replayConsoleLog, and handle remaining buffered bytes before closing controller.
Utilities
packages/react-on-rails-pro/src/utils.ts
Added exported replayConsoleLog(consoleReplayScript?: string, sanitizedNonce?: string) that extracts script content, creates a <script> with optional nonce, appends to document.body, and no-ops outside browser.
Tests
packages/react-on-rails-pro/tests/*
Updated tests to use NDJSON chunk helpers and object payloads: injectRSCPayload.test.ts, RSCRequestTracker.test.ts, registerServerComponent.client.test.jsx — added coverage for split-chunk boundaries, multibyte UTF-8 split, CRLF normalization, malformed NDJSON error emission, and adjusted assertions to expect direct JSON embedding in .push(...) calls.
TanStack router minor tweaks
packages/react-on-rails-pro/src/tanstack-router/...
Small type/coercion adjustments: pass railsContext without ad-hoc casting and coerce search in URL construction; no control-flow changes.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PreloadedStore as Preloaded\nPayloads
  participant Stream
  participant Parser as NDJSON\nParser
  participant Utils as replayConsoleLog
  participant DOM

  Client->>PreloadedStore: read RSCPayloadChunk[]
  PreloadedStore->>Stream: createReadableStream(Uint8Array chunks)
  Stream->>Parser: decode incremental bytes -> split NDJSON lines
  Parser->>Utils: extract consoleReplayScript (if any)
  Utils->>DOM: inject <script nonce?> with replay code
  Parser->>Client: push parsed RSC chunks into React model
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • AbanoubGhadban

Poem

🐰 I nibble bytes and trim the fluff,
NDJSON lines now travel light and tough.
No double-quote, no extra spin,
Console logs replayed with nonce to win.
Lighter payloads—hop, hop, hooray!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title references the core optimization being reapplied (RSC payload embedding), but the 'NO MERGE' prefix may mislead reviewers about the PR's actual intent and readiness for merge. Consider clarifying the title to indicate this is ready for review (e.g., 'Reapply RSC payload embedding optimization (#2835)') or confirm with maintainers if 'NO MERGE' is a temporary blocker that should appear in the description rather than the title.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg-codex/no-merge-replacement-2835-main

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 28, 2026

Greptile Summary

This PR reapplies the RSC payload embedding optimization from #2835, eliminating the double JSON.stringify that was adding ~38KB (~24%) of unnecessary overhead to every RSC payload script tag injected into the HTML stream.

Key changes:

  • injectRSCPayload.ts: Each raw NDJSON line from the RSC stream is now embedded directly as a JavaScript object literal in the .push(...) call, instead of being re-stringified into a JSON string. Proper per-line NDJSON buffering is added to handle chunk boundaries that don't align with \ , and a per-stream TextDecoder with { stream: true } correctly handles multi-byte UTF-8 characters split across chunks (fixing a pre-existing silent corruption bug). A final decoder.decode() flush after the loop ensures the last incomplete line is always processed.
  • getReactServerComponent.client.ts: The client-side preloaded-payload path is updated to consume RSCPayloadChunk objects directly (as pushed by the new script tags) instead of JSON strings. transformRSCStreamAndReplayConsoleLogs is no longer needed in this path; chunk.html is extracted and console logs replayed inline.
  • transformRSCStreamAndReplayConsoleLogs.ts: Uses the newly extracted replayConsoleLog utility; also gains the same final-chunk flush fix for the network fetch path.
  • utils.ts: replayConsoleLog extracted as a shared helper used by both code paths.
  • Tests: All existing tests updated to match the new (unescaped) output format; four new tests added covering boundary splitting, multi-byte UTF-8 handling, CRLF normalization, and malformed-JSON error propagation.

Confidence Score: 5/5

Safe to merge — optimization is well-scoped, HTML-injection safety is preserved by the existing escapeScript helper, and the test suite covers all meaningful edge cases.

No P0 or P1 findings. The optimization correctly relies on the JSON-is-a-strict-subset-of-JS property; escapeScript continues to neutralize the only two HTML-parser threats inside a script tag.

No files require special attention.

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/injectRSCPayload.ts Core of the optimization: removed double JSON.stringify, added proper NDJSON line-level buffering with multi-byte UTF-8 handling, and per-stream TextDecoder
packages/react-on-rails-pro/src/getReactServerComponent.client.ts Adapts client-side preloaded-payload path to receive RSCPayloadChunk objects instead of JSON strings
packages/react-on-rails-pro/src/transformRSCStreamAndReplayConsoleLogs.ts Uses shared replayConsoleLog utility; adds final-chunk flush fix for the fetch path
packages/react-on-rails-pro/src/utils.ts Extracts replayConsoleLog as a shared utility
packages/react-on-rails-pro/tests/injectRSCPayload.test.ts Updated and extended with 4 new edge-case tests
packages/react-on-rails-pro/tests/registerServerComponent.client.test.jsx Updated fixture setup to use parsed objects instead of re-stringified values
packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts Updated assertion to verify raw JS expression embedding

Reviews (1): Last reviewed commit: "Reapply "Eliminate double JSON.stringify..." | Re-trigger Greptile

Comment thread packages/react-on-rails-pro/src/getReactServerComponent.client.ts Outdated
Comment thread packages/react-on-rails-pro/src/getReactServerComponent.client.ts
Comment thread packages/react-on-rails-pro/src/injectRSCPayload.ts
Comment thread packages/react-on-rails-pro/src/injectRSCPayload.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 28, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.63 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.63 KB (+0.01% 🔺)
react-on-rails/client bundled (brotli) 53.7 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.7 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.56 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.56 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.59 KB (-0.03% 🔽)
react-on-rails-pro/client bundled (brotli) (time) 54.6 KB (0%)
registerServerComponent/client bundled (gzip) 127.5 KB (+0.09% 🔺)
registerServerComponent/client bundled (gzip) (time) 127.5 KB (+0.09% 🔺)
registerServerComponent/client bundled (brotli) 61.75 KB (+0.37% 🔺)
registerServerComponent/client bundled (brotli) (time) 61.75 KB (+0.37% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 122.13 KB (+0.1% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.13 KB (+0.1% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 56.85 KB (+0.15% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.85 KB (+0.15% 🔺)

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Mar 28, 2026

Review

The optimization is well-motivated (~24% payload reduction is meaningful) and the implementation is clean. The new NDJSON line-buffering logic correctly handles chunk boundary splits and multibyte UTF-8, and the test coverage for those edge cases (boundary splits, CRLF, malformed JSON) is solid.

Breaking change — requires coordinated deployment

The format of window.REACT_ON_RAILS_RSC_PAYLOADS changes from string[] to RSCPayloadChunk[]. Old client + new server (or vice versa) will silently break hydration:

  • Old server pushed JSON.stringify(chunk) — a string.
  • New server pushes {html, consoleReplayScript} — an object.
  • Old client expected a string and did JSON.parse on it via transformRSCStreamAndReplayConsoleLogs.
  • New client expects an object and reads .html directly.

With a rolling deployment (old and new pods serving simultaneously), mismatched versions produce a silent blank render — chunk.html is undefined on an old-format string, so encoder.encode(undefined) emits empty bytes and React renders nothing with no error thrown.

Recommendation: document this as a hard version-lock between the server-side and client-side packages, and consider adding a runtime format-detection guard (e.g. if (typeof payloads[0] === 'string') throw new Error('RSC payload format mismatch: redeploy client and server together')) to surface a clear error instead of a silent blank render.

Console log replay ordering change

Previously createFromPreloadedPayloads piped through transformRSCStreamAndReplayConsoleLogs, which replayed console logs lazily as React consumed the stream. Now replayConsoleLog is called eagerly inside the ReadableStream.start callback, so all console logs fire synchronously before React reads any data. Functionally equivalent in production, but the ordering difference may affect developer tooling or tests that assert console output timing.

Escaping correctness

The escapeScript function correctly covers the two HTML-in-script injection vectors (</script> and <!--). JSON numeric/boolean/null values can never contain these sequences; only JSON string values can, and the existing escaping handles them safely. No new XSS surface introduced.

Minor items (see inline comments)

  • payloads.push return value deviates from Array.prototype.push spec (line 132)
  • typeof window vs typeof document guard asymmetry (line 138)
  • JSON.parse validation cost and error truncation in injectRSCPayload.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CHANGELOG.md`:
- Around line 34-35: Update the changelog entry in CHANGELOG.md so it points to
the actual landing PR 2879 instead of PR 2835: replace the PR reference text
"[PR 2835](https://github.com/shakacode/react_on_rails/pull/2835) by
[justin808](https://github.com/justin808)" with the correct reference to PR 2879
and the appropriate author/PR URL (e.g., "[PR
2879](https://github.com/shakacode/react_on_rails/pull/2879) by <author>")
ensuring the displayed PR number and link match the merged PR that shipped the
change.

In `@packages/react-on-rails-pro/src/getReactServerComponent.client.ts`:
- Around line 128-133: The current override of payloads.push replaces the array
storage and prevents new chunks from being retained, which breaks multiple
consumers; fix by preserving the backing array: capture the original push (e.g.,
const originalPush = payloads.push.bind(payloads)) and replace payloads.push
with a wrapper that calls originalPush(...chunks) to store them, then iterates
chunks.forEach(handleChunk), and returns the originalPush result; reference the
payloads variable and handleChunk function when making the change so preloaded
chunks are both emitted and kept for future readers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e4719a3d-9efd-4ad9-b99b-d2c7da5feef5

📥 Commits

Reviewing files that changed from the base of the PR and between 395fa1f and 3a20853.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • packages/react-on-rails-pro/src/getReactServerComponent.client.ts
  • packages/react-on-rails-pro/src/injectRSCPayload.ts
  • packages/react-on-rails-pro/src/transformRSCStreamAndReplayConsoleLogs.ts
  • packages/react-on-rails-pro/src/utils.ts
  • packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts
  • packages/react-on-rails-pro/tests/injectRSCPayload.test.ts
  • packages/react-on-rails-pro/tests/registerServerComponent.client.test.jsx

Comment thread CHANGELOG.md Outdated
Comment thread packages/react-on-rails-pro/src/getReactServerComponent.client.ts
…lacement-2835-main

* origin/main: (44 commits)
  Fix Content-Length mismatch and null renderingRequest errors in node renderer (#3069)
  Improve memory debugging docs with simpler heap snapshot approach (#3072)
  Enforce strict version matching in doctor and recommend doctor on errors (#3070)
  Remove immediate_hydration feature from codebase (#2834)
  Fix infinite fork loop when node renderer worker fails to bind port (#2881)
  Fix TanStack Router SSR hydration mismatch in Pro async path (#2932)
  Improve node renderer error messages for malformed render requests (#3068)
  Add interactive mode prompt to create-react-on-rails-app (#3063)
  docs: replace hardcoded version numbers with unversioned install commands (#2893)
  Remove Contributing section from docs sidebar (#3064)
  Consolidate docs comparison pages into single evaluation entry (#3065)
  Docs route cleanup: canonicalize worst verbose URL slugs (#3067)
  Add agent summary blocks to high-value docs pages (#3066)
  Fix brittle positional assertions in create-app tests (#2923)
  Auto-resolve renderer password from ENV in Rails Pro (#2921)
  Standardize bundle env vars from =yes to =true (#2925)
  Fix Pro generator multiline and template-literal rewrites (#2918)
  Fix spec/dummy Procfile.dev SERVER_BUNDLE_ONLY to match template convention (#2922)
  Add llms docs entry points (#2916)
  Bump version to 16.6.0.rc.0
  ...

# Conflicts:
#	CHANGELOG.md
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts (1)

255-255: Byte-size comments are now approximate after NDJSON wrapping.

Since each chunk is wrapped as JSON plus \n, totals are slightly above the stated 32KB/64KB/128KB. Consider wording these as “~32KB/~64KB/~128KB” to keep comments precise.

Also applies to: 280-280, 336-336, 360-360

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts` at line 255,
Update the inline comments that state exact chunk sizes around calls to
pushNDJSONChunks in RSCRequestTracker.test to indicate approximate sizes (e.g.,
change "32KB/64KB/128KB" to "~32KB/~64KB/~128KB") because NDJSON wrapping adds
extra bytes; locate the comments adjacent to the pushNDJSONChunks(...) calls
(the ones using chunkSize 'z' and the other pushNDJSONChunks invocations) and
adjust the wording in each occurrence so the comments reflect approximate
totals.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts`:
- Line 255: Update the inline comments that state exact chunk sizes around calls
to pushNDJSONChunks in RSCRequestTracker.test to indicate approximate sizes
(e.g., change "32KB/64KB/128KB" to "~32KB/~64KB/~128KB") because NDJSON wrapping
adds extra bytes; locate the comments adjacent to the pushNDJSONChunks(...)
calls (the ones using chunkSize 'z' and the other pushNDJSONChunks invocations)
and adjust the wording in each occurrence so the comments reflect approximate
totals.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 21a970cd-f6f6-4b64-b536-a51c27842eee

📥 Commits

Reviewing files that changed from the base of the PR and between 3a20853 and 50ff949.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • packages/react-on-rails-pro/src/getReactServerComponent.client.ts
  • packages/react-on-rails-pro/src/tanstack-router/index.ts
  • packages/react-on-rails-pro/src/tanstack-router/serverRender.ts
  • packages/react-on-rails-pro/tests/RSCRequestTracker.test.ts
  • packages/react-on-rails-pro/tests/injectRSCPayload.test.ts
  • packages/react-on-rails-pro/tests/registerServerComponent.client.test.jsx
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/react-on-rails-pro/tests/injectRSCPayload.test.ts
  • packages/react-on-rails-pro/tests/registerServerComponent.client.test.jsx
  • packages/react-on-rails-pro/src/getReactServerComponent.client.ts

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 8, 2026

Code Review

Summary

The optimization is sound: embedding validated JSON directly as a JavaScript expression (instead of double-stringifying it) saves one full round-trip through the serializer/deserializer on both server and client. The performance gains (~38KB, ~24% reduction) are plausible and well-explained.


Security Analysis: JSON-as-JS Embedding

The comment in createRSCPayloadChunk says "Valid JSON is a strict subset of JavaScript expressions, so parseable JSON is safe to embed." This is correct for ES2019+ environments, which all supported browsers now implement. Two specific edge cases are handled properly:

  • </script><\/script>: The HTML parser won't see a premature script close. The JS engine evaluates \/ as /, restoring the original value. ✓
  • <!--<\!--: The HTML parser sees no comment opener. The JS engine evaluates \! as ! (unrecognized escape, backslash dropped in both strict and sloppy mode — only numeric legacy octal escapes are banned in strict mode). ✓

The JSON.parse validation gate in createRSCPayloadChunk before embedding is essential: it ensures malformed NDJSON emits a proper stream error instead of embedding syntactically invalid JavaScript in the page.


Issues

1. payloads.push override is permanent and compounds on re-invocation

createRSCStreamFromPreloadedPayloads permanently replaces payloads.push with a wrapper. If the same payload array is passed to createFromPreloadedPayloads a second time — which React 18 Strict Mode does by double-invoking effects — the override chains: each push would call all accumulated handlers. The closed guard in handleChunk prevents data from being enqueued into a closed stream, but the handler chain keeps growing.

More critically, payloads.push is never restored to its original. Any code that holds a reference to the array and pushes into it after the component unmounts will trigger all accumulated handlers (even if they short-circuit). Consider saving and restoring payloads.push in a cancel handler on the ReadableStream.

2. ReadableStream has no cancel handler

const stream = new ReadableStream<Uint8Array>({
  start(controller) { ... },
  // No cancel handler
});

If the consumer cancels the stream (e.g., the component unmounts mid-hydration, or Suspense abandons the render), payloads.push stays overridden indefinitely. A cancel handler that restores payloads.push = previousPush and sets closed = true would make cleanup deterministic.

3. JSON.parse validation runs on every NDJSON line in the server hot path

createRSCPayloadChunk calls JSON.parse(jsonLine) purely for validation, then discards the parsed result. For payloads with many small lines (e.g., streaming component-by-component), this is one extra full parse per line on the server. Since the upstream RSC stream is already valid NDJSON by contract, consider whether validation belongs here or at a boundary closer to where the stream originates. At minimum, the error message truncation to 100 chars is appropriate to avoid leaking large payload bodies in server logs.


Minor Notes

  • The final-chunk flush in transformRSCStreamAndReplayConsoleLogs (lastIncompleteChunk + decoder.decode()) is a real bug fix for streams that don't end with \n — good catch.
  • The decoder.decode(chunk, { stream: true }) upgrade (adds stream: true) correctly handles multibyte UTF-8 characters split across Network chunks. The new test covering this case is important.
  • Removing the as RailsContext & { serverSide: true } casts in tanstack-router/index.ts is the right call — those casts were suppressing type errors rather than fixing them.
  • createNDJSONChunk in RSCRequestTracker.test.ts wraps data in { chunk: ... } objects rather than RSCPayloadChunk-shaped objects. This is fine for throughput/deadlock tests, but if future tests need to verify the client-side consumption of the embedded payload, they'll need fixtures with html/consoleReplayScript fields.

Comment on lines +128 to 135
const previousPush = payloads.push.bind(payloads);
payloads.forEach(handleChunk);
// eslint-disable-next-line no-param-reassign
payloads.push = (...chunks) => {
payloads.push = (...chunks: RSCPayloadChunk[]) => {
const newLength = previousPush(...chunks);
chunks.forEach(handleChunk);
return chunks.length;
return newLength;
};
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 payloads.push override is permanent and never cleaned up. Two concerns:

  1. React Strict Mode double-invocation: In React 18 Strict Mode, effects (and the start callback path that triggers this) can be invoked twice. If createFromPreloadedPayloads is called twice with the same payloads array, the override chains — previousPush in the second call captures the already-wrapped push from the first. Each subsequent payloads.push(chunk) then calls both handlers.

  2. No cleanup on stream cancel: If the consumer cancels the stream (component unmounts mid-hydration), payloads.push remains overridden for the lifetime of the page.

Consider adding a cancel handler that restores the original push and prevents future handler invocations:

Suggested change
const previousPush = payloads.push.bind(payloads);
payloads.forEach(handleChunk);
// eslint-disable-next-line no-param-reassign
payloads.push = (...chunks) => {
payloads.push = (...chunks: RSCPayloadChunk[]) => {
const newLength = previousPush(...chunks);
chunks.forEach(handleChunk);
return chunks.length;
return newLength;
};
const previousPush = payloads.push.bind(payloads);
payloads.forEach(handleChunk);
// eslint-disable-next-line no-param-reassign
payloads.push = (...chunks: RSCPayloadChunk[]) => {
const newLength = previousPush(...chunks);
chunks.forEach(handleChunk);
return newLength;
};
streamController = controller;
  cancel() {
    closed = true;
    // Restore the original push so late-arriving script tags don't
    // accumulate handlers on a stream that's no longer reading.
    payloads.push = previousPush;
  },

(Requires hoisting previousPush out of start so cancel can reference it.)

Comment on lines +54 to +58
try {
JSON.parse(jsonLine);
} catch {
throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`);
}
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 runs on every NDJSON line purely for validation, then discards the result. For high-throughput RSC streams (many small chunks), this is a full parse-plus-GC per line on the server critical path.

The upstream RSC stream is NDJSON by contract (React generates valid JSON). If the validation is specifically guarding against malformed input from untrusted sources, it belongs here. But if this is defensive coding against bugs in React's own serializer, the overhead may not be worth it in production.

One middle ground: run the validation only in development (process.env.NODE_ENV !== 'production'), since broken RSC serialization would also manifest as hydration errors on the client anyway.

Suggested change
try {
JSON.parse(jsonLine);
} catch {
throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`);
}
try {
JSON.parse(jsonLine);
} catch {
throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`);
}

Could become:

  if (process.env.NODE_ENV !== 'production') {
    try {
      JSON.parse(jsonLine);
    } catch {
      throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`);
    }
  }

This trades a production-only performance win for dev-time error clarity. Worth deciding explicitly rather than leaving the validation always-on by default.

Comment on lines +83 to +86
const finalDecodedValue = lastIncompleteChunk + decoder.decode();
if (finalDecodedValue.trim() !== '') {
parseAndHandleLines(finalDecodedValue.split('\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.

Good fix — this handles the case where the RSC stream ends without a trailing \n, which would otherwise silently drop the last NDJSON object.

One edge case: if the stream ends with only whitespace (e.g., a trailing \r\n), finalDecodedValue.trim() !== '' correctly skips it. But parseAndHandleLines internally does line.trim() !== '' filtering, so even without the outer trim() guard it would be a no-op. The outer check is still good as an early-exit optimization. ✓

} catch {
throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`);
}
return createScriptTag(`(${cacheKeyJSArray(cacheKey)}).push(${jsonLine})`, sanitizedNonce);
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.

Security note (informational, not blocking): The claim "Valid JSON is a strict subset of JavaScript expressions" is accurate for ES2019+ environments. Two specific sequences that could break HTML parsing are handled correctly by escapeScript():

  • </script><\/script>: HTML parser doesn't see the close tag; JS engine evaluates \/ as /. ✓
  • <!--<\!--: HTML parser sees no comment opener; JS engine evaluates \! as ! (unrecognized escape — only numeric legacy octal escapes are banned in strict mode, so \! is valid in both strict and sloppy mode). ✓

U+2028 / U+2029 (LINE SEPARATOR / PARAGRAPH SEPARATOR): valid JSON, valid in ES2019+ JS string literals. All supported browsers handle this. ✓

The approach is safe. The comment explaining it is accurate and sufficient.

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