-
-
Notifications
You must be signed in to change notification settings - Fork 634
NO MERGE: Replacement for #2835 (reapply RSC payload embedding optimization) #2879
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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[]>; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 on lines
+128
to
135
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Consider adding a
Suggested change
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 |
||||||||||||||||||||||||||||||||||||||||
| streamController = controller; | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (typeof document !== 'undefined' && document.readyState === 'loading') { | ||||||||||||||||||||||||||||||||||||||||
|
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); | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||||||||||||
|
justin808 marked this conversation as resolved.
|
||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||
| throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`); | ||||||||||||||||||||||
|
justin808 marked this conversation as resolved.
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+54
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 (
Suggested change
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); | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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. |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // ======================================== | ||||||||||||||||||||||
|
|
@@ -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(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 One edge case: if the stream ends with only whitespace (e.g., a trailing |
||
|
|
||
| controller.close(); | ||
| } catch (error) { | ||
| console.error('Error transforming RSC stream:', error); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.