-
-
Notifications
You must be signed in to change notification settings - Fork 635
Revert "Eliminate double JSON.stringify in RSC payload embedding (#2835)" #2878
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
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -46,17 +46,8 @@ function createRSCPayloadInitializationScript(cacheKey: string, sanitizedNonce?: | |||||
| return createScriptTag(cacheKeyJSArray(cacheKey), 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); | ||||||
| } catch { | ||||||
| throw new Error(`Malformed NDJSON line in RSC stream: ${jsonLine.slice(0, 100)}`); | ||||||
| } | ||||||
| return createScriptTag(`(${cacheKeyJSArray(cacheKey)}).push(${jsonLine})`, sanitizedNonce); | ||||||
| function createRSCPayloadChunk(chunk: string, cacheKey: string, sanitizedNonce?: string) { | ||||||
| return createScriptTag(`(${cacheKeyJSArray(cacheKey)}).push(${JSON.stringify(chunk)})`, sanitizedNonce); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
|
|
@@ -97,6 +88,7 @@ export default function injectRSCPayload( | |||||
| safePipe(pipeableHtmlStream, htmlStream, (err) => { | ||||||
| resultStream.emit('error', err); | ||||||
| }); | ||||||
| const decoder = new TextDecoder(); | ||||||
| let rscPromise: Promise<void> | null = null; | ||||||
|
|
||||||
| // ======================================== | ||||||
|
|
@@ -259,37 +251,12 @@ export default function injectRSCPayload( | |||||
| const initializationScript = createRSCPayloadInitializationScript(rscPayloadKey, sanitizedNonce); | ||||||
| rscInitializationBuffers.push(Buffer.from(initializationScript)); | ||||||
|
|
||||||
| // Process RSC payload stream asynchronously. | ||||||
| // Chunks may not align with JSON object boundaries, so we buffer | ||||||
| // incomplete lines (same approach as transformRSCStream). | ||||||
| // Process RSC payload stream asynchronously | ||||||
| rscPromises.push( | ||||||
| (async () => { | ||||||
| let lastIncompleteLine = ''; | ||||||
| const decoder = new TextDecoder(); | ||||||
| for await (const chunk of stream ?? []) { | ||||||
| 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); | ||||||
| const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk); | ||||||
|
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. Bug: TextDecoder reused without A single
Suggested change
The corresponding test ("handles chunks that split a multibyte UTF-8 character") was removed by this revert — it should be kept (or re-added in the follow-up PR) to guard this path. 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 RSC payload loop decodes each binary chunk with Useful? React with 👍 / 👎. 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.
PR #2835 fixed this with |
||||||
| const payloadScript = createRSCPayloadChunk(decodedChunk, 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, replayConsoleLog } from './utils.ts'; | ||||||
| import { sanitizeNonce } from './utils.ts'; | ||||||
|
|
||||||
| /** | ||||||
| * Transforms an RSC stream and replays console logs on the client. | ||||||
|
|
@@ -45,46 +45,48 @@ 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; | ||||||
| } | ||||||
| }); | ||||||
|
|
||||||
| for (const jsonChunk of jsonChunks) { | ||||||
| handleJsonChunk(jsonChunk); | ||||||
| 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); | ||||||
| } | ||||||
|
Comment on lines
+55
to
62
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.
if (typeof document === 'undefined') {
return;
}Although this code path lives in a |
||||||
| }; | ||||||
|
|
||||||
| try { | ||||||
| while (!done) { | ||||||
| const decodedValue = typeof value === 'string' ? value : decoder.decode(value, { stream: true }); | ||||||
| const decodedValue = typeof value === 'string' ? value : decoder.decode(value); | ||||||
|
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. Same
Suggested change
|
||||||
| const decodedChunks = lastIncompleteChunk + decodedValue; | ||||||
| const chunks = decodedChunks.split('\n'); | ||||||
| lastIncompleteChunk = chunks.pop() ?? ''; | ||||||
|
|
||||||
| parseAndHandleLines(chunks); | ||||||
| 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); | ||||||
| } | ||||||
|
|
||||||
| // 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')); | ||||||
| } | ||||||
|
|
||||||
| controller.close(); | ||||||
|
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. Silent data loss: the final incomplete NDJSON line is never flushed. When the upstream stream ends, PR #2835 fixed this by flushing after the loop: const finalDecodedValue = lastIncompleteChunk + decoder.decode(); // flush remaining bytes
if (finalDecodedValue.trim() !== '') {
parseAndHandleLines(finalDecodedValue.split('\n'));
}This is a pre-existing regression worth carrying forward as a fix in the follow-up PR. 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 transformer keeps partial NDJSON in Useful? React with 👍 / 👎.
Comment on lines
88
to
90
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.
After the PR #2835 fixed this by processing const finalDecodedValue = lastIncompleteChunk + decoder.decode();
if (finalDecodedValue.trim() !== '') {
parseAndHandleLines(finalDecodedValue.split('\n'));
}This is a known regression being reintroduced and should be tracked for the planned follow-up PR. |
||||||
| } catch (error) { | ||||||
| console.error('Error transforming RSC stream:', error); | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -222,10 +222,10 @@ describe('RSCRequestTracker', () => { | |
| expect(result).toContain('<html><body>'); | ||
| expect(result).toContain('</body></html>'); | ||
|
|
||
| // Output must contain the RSC payload initialization and data scripts. | ||
| // The payload JSON is embedded directly as a JS expression (not re-stringified). | ||
| // Output must contain the RSC payload initialization and data scripts | ||
| expect(result).toContain('REACT_ON_RAILS_RSC_PAYLOADS'); | ||
| expect(result).toContain(`.push(${payload})`); | ||
| expect(result).toContain('.push('); | ||
| expect(result).toContain(JSON.stringify(payload)); | ||
|
Comment on lines
+227
to
+228
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. Weakened assertion: the two The prior assertion Consider tightening it back to: expect(result).toContain(`.push(${JSON.stringify(payload)})`); |
||
| }); | ||
|
|
||
| it('does not deadlock with large multi-chunk payloads exceeding the default highWaterMark', async () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
typeof window === 'undefined', thestartcallback returns early without closingcontrollerand without assigningstreamController. The code executed after thenew ReadableStream(...)constructor also silently no-ops becausestreamControllerisundefined. Any consumer reading from this stream will hang indefinitely. The version from PR #2835 explicitly calledcontroller.close()in this branch. While this path is unlikely to be exercised in practice (callers guard onwindow.REACT_ON_RAILS_RSC_PAYLOADS), it is worth tracking for the follow-up PR.