Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

### [Unreleased]

#### Performance

- **[Pro]** **Reduce RSC payload overhead by eliminating double JSON.stringify**: RSC Flight data embedded in
HTML stream script tags was being JSON.stringified twice — once when wrapping in the result object envelope,
and again when embedding in the `<script>` tag's `.push()` call. The second stringify is now eliminated by
embedding JSON directly as a JavaScript expression (JSON is a strict subset of JS). This saves ~38KB (~24%
of raw Flight data) on a typical product search page with 36 results.
[PR 2879](https://github.com/shakacode/react_on_rails/pull/2879) by
[justin808](https://github.com/justin808).
Fixes [Issue 2522](https://github.com/shakacode/react_on_rails/issues/2522).

#### Removed

- **Removed `immediate_hydration` configuration and parameter**: The `immediate_hydration` config option, helper parameter, `data-immediate-hydration` HTML attribute, and `redux_store` `immediate_hydration:` keyword argument have been completely removed. Immediate hydration is now always enabled for React on Rails Pro users and disabled for non-Pro users, with no per-component override. Remove any `immediate_hydration` references from your initializer and helper calls. Passing `immediate_hydration:` to `react_component` / `react_component_hash` is now ignored, and passing it to `stream_react_component` logs a warning. This change also fixes HTML attribute escaping for redux store names to prevent attribute injection from unsafe store keys. Closes [Issue 2142](https://github.com/shakacode/react_on_rails/issues/2142).
Expand Down
70 changes: 47 additions & 23 deletions packages/react-on-rails-pro/src/getReactServerComponent.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@

import * as React from 'react';
import { createFromReadableStream } from 'react-on-rails-rsc/client.browser';
import { RailsContext } from 'react-on-rails/types';
import { createRSCPayloadKey, fetch, wrapInNewPromise, extractErrorMessage } from './utils.ts';
import { RailsContext, RSCPayloadChunk } from 'react-on-rails/types';
import {
createRSCPayloadKey,
fetch,
wrapInNewPromise,
extractErrorMessage,
sanitizeNonce,
replayConsoleLog,
} from './utils.ts';
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs.ts';

declare global {
interface Window {
REACT_ON_RAILS_RSC_PAYLOADS?: Record<string, string[]>;
REACT_ON_RAILS_RSC_PAYLOADS?: Record<string, RSCPayloadChunk[]>;
}
}

Expand Down Expand Up @@ -91,56 +98,73 @@ const fetchRSC = ({
}
};

const createRSCStreamFromArray = (payloads: string[]) => {
let streamController: ReadableStreamController<string> | undefined;
const stream = new ReadableStream<string>({
/**
* Creates a ReadableStream of raw Flight data from preloaded RSC payload objects.
*
* The payloads are objects (not strings) because injectRSCPayload embeds JSON
* directly as JavaScript expressions, avoiding the double JSON.stringify overhead.
* This function extracts the html field and replays console logs from each chunk.
*/
const createRSCStreamFromPreloadedPayloads = (payloads: RSCPayloadChunk[], cspNonce?: string) => {
const encoder = new TextEncoder();
const sanitizedNonceValue = sanitizeNonce(cspNonce);
let streamController: ReadableStreamController<Uint8Array> | undefined;
let closed = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
// Browser-only by design (callers read from window.REACT_ON_RAILS_RSC_PAYLOADS).
// If called outside the browser, close immediately to avoid hanging streams.
if (typeof window === 'undefined') {
closed = true;
controller.close();
return;
}
const handleChunk = (chunk: string) => {
controller.enqueue(chunk);
const handleChunk = (chunk: RSCPayloadChunk) => {
if (closed) return;
controller.enqueue(encoder.encode(chunk.html ?? ''));
replayConsoleLog(chunk.consoleReplayScript, sanitizedNonceValue);
};

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;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +128 to 135
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.)

streamController = controller;
},
});

if (typeof document !== 'undefined' && document.readyState === 'loading') {
Comment thread
justin808 marked this conversation as resolved.
document.addEventListener('DOMContentLoaded', () => {
closed = true;
streamController?.close();
});
} else {
// Once parsing is past "loading", all inline <script> tags that push into this array
// have already executed, so the preloaded payload list is complete and can be closed now.
closed = true;
streamController?.close();
}

return stream;
};

/**
* Creates React elements from preloaded RSC payloads in the page.
* Creates React elements from preloaded RSC payloads embedded in the page.
*
* This function:
* 1. Creates a ReadableStream from the array of payload chunks
* 2. Transforms the stream to handle console logs and other processing
* 3. Uses React's createFromReadableStream to process the payload
* The payloads are RSCPayloadChunk objects pushed to the global array by
* injectRSCPayload's script tags. This processes them directly without
* JSON.parse overhead (the objects are already parsed by the JS engine).
*
* This is used during hydration to avoid making HTTP requests when
* the payload is already embedded in the page.
*
* @param payloads - Array of RSC payload chunks from the global array
* @param payloads - Array of RSC payload chunk objects from the global array
* @returns A Promise resolving to the rendered React element
*/
const createFromPreloadedPayloads = (payloads: string[], cspNonce?: string) => {
const stream = createRSCStreamFromArray(payloads);
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream, cspNonce);
const renderPromise = createFromReadableStream<React.ReactNode>(transformedStream);
const createFromPreloadedPayloads = (payloads: RSCPayloadChunk[], cspNonce?: string) => {
const stream = createRSCStreamFromPreloadedPayloads(payloads, cspNonce);
const renderPromise = createFromReadableStream<React.ReactNode>(stream);
return wrapInNewPromise(renderPromise);
};

Expand Down
45 changes: 39 additions & 6 deletions packages/react-on-rails-pro/src/injectRSCPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,17 @@ function createRSCPayloadInitializationScript(cacheKey: string, sanitizedNonce?:
return createScriptTag(cacheKeyJSArray(cacheKey), sanitizedNonce);
}

function createRSCPayloadChunk(chunk: string, cacheKey: string, sanitizedNonce?: string) {
return createScriptTag(`(${cacheKeyJSArray(cacheKey)}).push(${JSON.stringify(chunk)})`, sanitizedNonce);
function createRSCPayloadChunk(jsonLine: string, cacheKey: string, sanitizedNonce?: string) {
// Embed the JSON line directly as a JavaScript expression instead of re-stringifying it.
// Valid JSON is a strict subset of JavaScript expressions, so parseable JSON is safe to embed.
// createScriptTag's escapeScript() handles HTML-unsafe sequences (</script>, <!--)
// using JS-compatible escape sequences that preserve the parsed value.
try {
JSON.parse(jsonLine);
Comment thread
justin808 marked this conversation as resolved.
} catch {
throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`);
Comment thread
justin808 marked this conversation as resolved.
}
Comment on lines +54 to +58
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.

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.

}

/**
Expand Down Expand Up @@ -88,7 +97,6 @@ export default function injectRSCPayload(
safePipe(pipeableHtmlStream, htmlStream, (err) => {
resultStream.emit('error', err);
});
const decoder = new TextDecoder();
let rscPromise: Promise<void> | null = null;

// ========================================
Expand Down Expand Up @@ -251,12 +259,37 @@ export default function injectRSCPayload(
const initializationScript = createRSCPayloadInitializationScript(rscPayloadKey, sanitizedNonce);
rscInitializationBuffers.push(Buffer.from(initializationScript));

// Process RSC payload stream asynchronously
// Process RSC payload stream asynchronously.
// Chunks may not align with JSON object boundaries, so we buffer
// incomplete lines (same approach as transformRSCStream).
rscPromises.push(
(async () => {
let lastIncompleteLine = '';
const decoder = new TextDecoder();
for await (const chunk of stream ?? []) {
const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
const payloadScript = createRSCPayloadChunk(decodedChunk, rscPayloadKey, sanitizedNonce);
const decodedChunk =
lastIncompleteLine +
(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
const lines = decodedChunk.split('\n');
lastIncompleteLine = lines.pop() ?? '';

// Contract: upstream stream emits NDJSON (one payload object per line).
let bufferedPayloadInThisChunk = false;
for (const line of lines) {
const normalizedLine = line.trim();
if (normalizedLine !== '') {
const payloadScript = createRSCPayloadChunk(normalizedLine, rscPayloadKey, sanitizedNonce);
rscPayloadBuffers.push(Buffer.from(payloadScript));
bufferedPayloadInThisChunk = true;
}
}
if (bufferedPayloadInThisChunk) {
scheduleFlush();
}
}
const finalChunk = (lastIncompleteLine + decoder.decode()).trim();
if (finalChunk !== '') {
const payloadScript = createRSCPayloadChunk(finalChunk, rscPayloadKey, sanitizedNonce);
rscPayloadBuffers.push(Buffer.from(payloadScript));
scheduleFlush();
}
Expand Down
4 changes: 2 additions & 2 deletions packages/react-on-rails-pro/src/tanstack-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function createTanStackRouterRenderFunction(
return serverRenderTanStackAppAsync(
options,
props,
railsContext as RailsContext & { serverSide: true },
railsContext,
RouterProvider,
createMemoryHistory,
).then(({ appElement, dehydratedState }) => ({
Expand All @@ -127,7 +127,7 @@ export function createTanStackRouterRenderFunction(
return clientHydrateTanStackApp(
options,
clientProps,
railsContext as RailsContext & { serverSide: false },
railsContext,
RouterProvider,
createBrowserHistory,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export async function serverRenderTanStackAppAsync(
createMemoryHistory: (opts: { initialEntries: string[] }) => TanStackHistory,
): Promise<TanStackServerRenderResult> {
const router = options.createRouter();
const url = railsContext.pathname + normalizeSearch(railsContext.search);
const url = `${railsContext.pathname}${normalizeSearch(railsContext.search as string | null | undefined)}`;

const memoryHistory = createMemoryHistory({ initialEntries: [url] });
router.update({ history: memoryHistory });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import { RSCPayloadChunk } from 'react-on-rails/types';
import { sanitizeNonce } from './utils.ts';
import { sanitizeNonce, replayConsoleLog } from './utils.ts';

/**
* Transforms an RSC stream and replays console logs on the client.
Expand Down Expand Up @@ -45,48 +45,46 @@ export default function transformRSCStreamAndReplayConsoleLogs(
let { value, done } = await reader.read();

const handleJsonChunk = (chunk: RSCPayloadChunk) => {
const { html, consoleReplayScript = '' } = chunk;
const { html, consoleReplayScript } = chunk;
controller.enqueue(encoder.encode(html ?? ''));
replayConsoleLog(consoleReplayScript, sanitizedNonce);
};

const parseAndHandleLines = (lines: string[]) => {
const jsonChunks = lines
.filter((line) => line.trim() !== '')
.map((line) => {
try {
return JSON.parse(line) as RSCPayloadChunk;
} catch (error) {
console.error('Error parsing JSON:', line, error);
throw error;
}
});

const replayConsoleCode = consoleReplayScript
.trim()
.replace(/^<script[^>]*>/i, '')
.replace(/<\/script>$/i, '');
if (replayConsoleCode?.trim() !== '') {
const scriptElement = document.createElement('script');
if (sanitizedNonce) {
scriptElement.nonce = sanitizedNonce;
}
scriptElement.textContent = replayConsoleCode;
document.body.appendChild(scriptElement);
for (const jsonChunk of jsonChunks) {
handleJsonChunk(jsonChunk);
}
};

try {
while (!done) {
const decodedValue = typeof value === 'string' ? value : decoder.decode(value);
const decodedValue = typeof value === 'string' ? value : decoder.decode(value, { stream: true });
const decodedChunks = lastIncompleteChunk + decodedValue;
const chunks = decodedChunks.split('\n');
lastIncompleteChunk = chunks.pop() ?? '';

const jsonChunks = chunks
.filter((line) => line.trim() !== '')
.map((line) => {
try {
return JSON.parse(line) as RSCPayloadChunk;
} catch (error) {
console.error('Error parsing JSON:', line, error);
throw error;
}
});

for (const jsonChunk of jsonChunks) {
handleJsonChunk(jsonChunk);
}
parseAndHandleLines(chunks);

// eslint-disable-next-line no-await-in-loop
({ value, done } = await reader.read());
}

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


controller.close();
} catch (error) {
console.error('Error transforming RSC stream:', error);
Expand Down
19 changes: 19 additions & 0 deletions packages/react-on-rails-pro/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,22 @@ export const sanitizeNonce = (nonce?: string) => {
const nonceWithAllowedCharsOnly = nonce?.replace(/[^a-zA-Z0-9+/=_-]/g, '');
return nonceWithAllowedCharsOnly?.match(/^[a-zA-Z0-9+/_-]+={0,2}$/)?.[0];
};

export function replayConsoleLog(consoleReplayScript: string | undefined, sanitizedNonce?: string) {
if (typeof document === 'undefined') {
return;
}

const replayConsoleCode = (consoleReplayScript ?? '')
.trim()
.replace(/^<script[^>]*>/i, '')
.replace(/<\/script>$/i, '');
if (replayConsoleCode?.trim() !== '') {
const scriptElement = document.createElement('script');
if (sanitizedNonce) {
scriptElement.nonce = sanitizedNonce;
}
scriptElement.textContent = replayConsoleCode;
document.body.appendChild(scriptElement);
}
}
Loading
Loading