Skip to content

Commit 11c97ce

Browse files
embed rsc payload inside the html page
1 parent 5c99bc4 commit 11c97ce

8 files changed

+244
-32
lines changed

lib/react_on_rails/helper.rb

+6-1
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,10 @@ def json_safe_and_pretty(hash_or_string)
362362
def rails_context(server_side: true)
363363
# ALERT: Keep in sync with node_package/src/types/index.ts for the properties of RailsContext
364364
@rails_context ||= begin
365+
rsc_url = if ReactOnRails::Utils.react_on_rails_pro?
366+
ReactOnRailsPro.configuration.rsc_payload_generation_url_path
367+
end
368+
365369
result = {
366370
componentRegistryTimeout: ReactOnRails.configuration.component_registry_timeout,
367371
railsEnv: Rails.env,
@@ -371,7 +375,8 @@ def rails_context(server_side: true)
371375
i18nDefaultLocale: I18n.default_locale,
372376
rorVersion: ReactOnRails::VERSION,
373377
# TODO: v13 just use the version if existing
374-
rorPro: ReactOnRails::Utils.react_on_rails_pro?
378+
rorPro: ReactOnRails::Utils.react_on_rails_pro?,
379+
rscPayloadGenerationUrl: rsc_url
375380
}
376381
if ReactOnRails::Utils.react_on_rails_pro?
377382
result[:rorProVersion] = ReactOnRails::Utils.react_on_rails_pro_version

node_package/src/RSCClientRoot.ts

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use client";
22

3+
/* eslint-disable no-underscore-dangle */
4+
35
import * as React from 'react';
46
import * as ReactDOMClient from 'react-dom/client';
57
import { createFromReadableStream } from 'react-on-rails-rsc/client';
@@ -13,6 +15,12 @@ if (typeof use !== 'function') {
1315
throw new Error('React.use is not defined. Please ensure you are using React 19 to use server components.');
1416
}
1517

18+
declare global {
19+
interface Window {
20+
__FLIGHT_DATA: unknown[];
21+
}
22+
}
23+
1624
export type RSCClientRootProps = {
1725
componentName: string;
1826
rscPayloadGenerationUrlPath: string;
@@ -35,6 +43,45 @@ const fetchRSC = ({ componentName, rscPayloadGenerationUrlPath, componentProps }
3543
return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?props=${propsString}`));
3644
}
3745

46+
const createRSCStreamFromPage = () => {
47+
let streamController: ReadableStreamController<unknown> | undefined;
48+
const stream = new ReadableStream({
49+
start(controller) {
50+
if (typeof window === 'undefined') {
51+
return;
52+
}
53+
const handleChunk = (chunk: unknown) => {
54+
controller.enqueue(chunk);
55+
};
56+
if (!window.__FLIGHT_DATA) {
57+
window.__FLIGHT_DATA = [];
58+
}
59+
window.__FLIGHT_DATA.forEach(handleChunk);
60+
window.__FLIGHT_DATA.push = (...chunks: unknown[]) => {
61+
chunks.forEach(handleChunk);
62+
return chunks.length;
63+
};
64+
streamController = controller;
65+
}
66+
});
67+
68+
if (typeof document !== 'undefined' && document.readyState === 'loading') {
69+
document.addEventListener('DOMContentLoaded', () => {
70+
streamController?.close();
71+
});
72+
} else {
73+
streamController?.close();
74+
}
75+
76+
return stream;
77+
}
78+
79+
const createFromRSCStream = () => {
80+
const stream = createRSCStreamFromPage();
81+
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
82+
return createFromReadableStream<React.ReactNode>(transformedStream);
83+
}
84+
3885
/**
3986
* RSCClientRoot is a React component that handles client-side rendering of React Server Components (RSC).
4087
* It manages the fetching, caching, and rendering of RSC payloads from the server.
@@ -53,7 +100,6 @@ const RSCClientRoot: RenderFunction = async ({
53100
rscPayloadGenerationUrlPath,
54101
componentProps,
55102
}: RSCClientRootProps, _railsContext?: RailsContext, domNodeId?: string) => {
56-
const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps })
57103
if (!domNodeId) {
58104
throw new Error('RSCClientRoot: No domNodeId provided');
59105
}
@@ -62,8 +108,10 @@ const RSCClientRoot: RenderFunction = async ({
62108
throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`);
63109
}
64110
if (domNode.innerHTML) {
111+
const root = await createFromRSCStream();
65112
ReactDOMClient.hydrateRoot(domNode, root);
66113
} else {
114+
const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps })
67115
ReactDOMClient.createRoot(domNode).render(root);
68116
}
69117
// Added only to satisfy the return type of RenderFunction
+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as React from 'react';
2+
3+
type StreamChunk = {
4+
chunk: string;
5+
isLastChunk: boolean;
6+
}
7+
8+
type RSCPayloadContainerProps = {
9+
RSCPayloadStream: NodeJS.ReadableStream;
10+
}
11+
12+
type RSCPayloadContainerInnerProps = {
13+
chunkIndex: number;
14+
getChunkPromise: (chunkIndex: number) => Promise<StreamChunk>;
15+
}
16+
17+
function escapeScript(script: string) {
18+
return script
19+
.replace(/<!--/g, '<\\!--')
20+
.replace(/<\/(script)/gi, '</\\$1');
21+
}
22+
23+
const RSCPayloadContainer = (
24+
{ chunkIndex, getChunkPromise }: RSCPayloadContainerInnerProps,
25+
): React.ReactNode => {
26+
const chunkPromise = getChunkPromise(chunkIndex);
27+
const chunk = React.use(chunkPromise);
28+
29+
const scriptElement = React.createElement('script', {
30+
dangerouslySetInnerHTML: {
31+
__html: escapeScript(`(self.__FLIGHT_DATA||=[]).push(${chunk.chunk})`),
32+
},
33+
key: `script-${chunkIndex}`,
34+
});
35+
36+
if (chunk.isLastChunk) {
37+
return scriptElement;
38+
}
39+
40+
return React.createElement(React.Fragment, null, [
41+
scriptElement,
42+
React.createElement(
43+
React.Suspense,
44+
{ fallback: null, key: `suspense-${chunkIndex}` },
45+
React.createElement(
46+
RSCPayloadContainer,
47+
{ chunkIndex: chunkIndex + 1, getChunkPromise },
48+
),
49+
),
50+
]);
51+
}
52+
53+
export default function RSCPayloadContainerWrapper({ RSCPayloadStream }: RSCPayloadContainerProps) {
54+
const [chunkPromises] = React.useState<Promise<StreamChunk>[]>(() => {
55+
const promises: Promise<StreamChunk>[] = [];
56+
let resolveCurrentPromise: (streamChunk: StreamChunk) => void = () => {};
57+
let rejectCurrentPromise: (error: Error) => void = () => {};
58+
const decoder = new TextDecoder();
59+
60+
const createNewPromise = () => {
61+
const promise = new Promise<StreamChunk>((resolve, reject) => {
62+
resolveCurrentPromise = resolve;
63+
rejectCurrentPromise = reject;
64+
});
65+
66+
promises.push(promise);
67+
};
68+
69+
createNewPromise();
70+
RSCPayloadStream.on('data', (streamChunk) => {
71+
resolveCurrentPromise({ chunk: decoder.decode(streamChunk), isLastChunk: false });
72+
createNewPromise();
73+
});
74+
75+
RSCPayloadStream.on('error', (error) => {
76+
rejectCurrentPromise(error);
77+
createNewPromise();
78+
});
79+
80+
RSCPayloadStream.on('end', () => {
81+
resolveCurrentPromise({ chunk: '', isLastChunk: true });
82+
});
83+
84+
return promises;
85+
});
86+
87+
const getChunkPromise = React.useCallback((chunkIndex: number) => {
88+
if (chunkIndex > chunkPromises.length) {
89+
throw new Error('React on Rails Error: RSC Chunk index out of bounds');
90+
}
91+
92+
return chunkPromises[chunkIndex];
93+
}, [chunkPromises]);
94+
95+
return React.createElement(
96+
React.Suspense,
97+
{ fallback: null },
98+
React.createElement(RSCPayloadContainer, { chunkIndex: 0, getChunkPromise }),
99+
);
100+
}

node_package/src/RSCServerRoot.ts

+23-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { createFromNodeStream } from 'react-on-rails-rsc/client.node';
33
import type { RenderFunction, RailsContext } from './types';
44
import transformRSCStream from './transformRSCNodeStreamAndReplayConsoleLogs';
55
import loadJsonFile from './loadJsonFile';
6+
import RSCPayloadContainer from './RSCPayloadContainer';
7+
import { PassThrough } from 'stream';
68

79
declare global {
810
function generateRSCPayload(
@@ -82,9 +84,28 @@ const RSCServerRoot: RenderFunction = async ({ componentName, componentProps }:
8284
componentProps,
8385
railsContext.serverSideRSCPayloadParameters
8486
);
85-
const serverComponentElement = createFromReactOnRailsNodeStream(rscPayloadStream, ssrManifest);
8687

87-
return () => use(serverComponentElement);
88+
// Tee the stream to pass it to the server component and the payload container
89+
const rscPayloadStream1 = new PassThrough();
90+
rscPayloadStream.pipe(rscPayloadStream1);
91+
const rscPayloadStream2 = new PassThrough();
92+
rscPayloadStream.pipe(rscPayloadStream2);
93+
const serverComponentElement = createFromReactOnRailsNodeStream(rscPayloadStream1, ssrManifest);
94+
95+
return () => React.createElement(
96+
React.Fragment,
97+
null, [
98+
React.createElement(
99+
React.Fragment,
100+
{ key: 'serverComponentElement' },
101+
use(serverComponentElement)
102+
),
103+
React.createElement(
104+
RSCPayloadContainer,
105+
{ RSCPayloadStream: rscPayloadStream2, key: 'rscPayloadContainer' },
106+
),
107+
]
108+
);
88109
};
89110

90111
export default RSCServerRoot;

node_package/src/buildConsoleReplay.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,9 @@ export function consoleReplay(customConsoleHistory: typeof console['history'] |
4646
}
4747

4848
export default function buildConsoleReplay(customConsoleHistory: typeof console['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0): string {
49-
return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory, numberOfMessagesToSkip));
49+
const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip);
50+
if (consoleReplayJS.length === 0) {
51+
return '';
52+
}
53+
return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplayJS);
5054
}

node_package/src/streamServerRenderedReactComponent.ts

+29-4
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,40 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
8787
const consoleHistory = console.history;
8888
let previouslyReplayedConsoleMessages = 0;
8989

90+
let consoleReplayTimeoutId: NodeJS.Timeout;
91+
const buildConsoleReplayChunk = () => {
92+
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
93+
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
94+
if (consoleReplayScript.length === 0) {
95+
return null;
96+
}
97+
const consoleReplayJsonChunk = JSON.stringify(createResultObject('', consoleReplayScript, renderState));
98+
return consoleReplayJsonChunk;
99+
}
100+
90101
const transformStream = new PassThrough({
91102
transform(chunk, _, callback) {
92103
const htmlChunk = chunk.toString();
93-
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
94-
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
104+
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, '', renderState));
105+
this.push(`${jsonChunk}\n`);
106+
107+
clearTimeout(consoleReplayTimeoutId);
108+
consoleReplayTimeoutId = setTimeout(() => {
109+
const consoleReplayChunk = buildConsoleReplayChunk();
110+
if (consoleReplayChunk) {
111+
this.push(`${consoleReplayChunk}\n`);
112+
}
113+
}, 0);
95114

96-
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
115+
callback();
116+
},
97117

98-
this.push(`${jsonChunk}\n`);
118+
flush(callback) {
119+
clearTimeout(consoleReplayTimeoutId);
120+
const consoleReplayChunk = buildConsoleReplayChunk();
121+
if (consoleReplayChunk) {
122+
this.push(`${consoleReplayChunk}\n`);
123+
}
99124
callback();
100125
}
101126
});

node_package/src/transformRSCStreamAndReplayConsoleLogs.ts

+31-22
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,41 @@ export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableS
77

88
let lastIncompleteChunk = '';
99
let { value, done } = await reader.read();
10-
while (!done) {
11-
const decodedValue = lastIncompleteChunk + decoder.decode(value);
12-
const chunks = decodedValue.split('\n');
13-
lastIncompleteChunk = chunks.pop() ?? '';
10+
11+
const handleJsonChunk = (chunk: { html: string, consoleReplayScript: string }) => {
12+
const { html, consoleReplayScript = '' } = chunk;
13+
controller.enqueue(encoder.encode(html));
1414

15-
const jsonChunks = chunks
16-
.filter(line => line.trim() !== '')
17-
.map((line) => {
18-
try {
19-
return JSON.parse(line);
20-
} catch (error) {
21-
console.error('Error parsing JSON:', line, error);
22-
throw error;
23-
}
24-
});
15+
const replayConsoleCode = consoleReplayScript.trim().replace(/^<script.*>/, '').replace(/<\/script>$/, '');
16+
if (replayConsoleCode?.trim() !== '') {
17+
const scriptElement = document.createElement('script');
18+
scriptElement.textContent = replayConsoleCode;
19+
document.body.appendChild(scriptElement);
20+
}
21+
};
22+
23+
while (!done) {
24+
if (ArrayBuffer.isView(value)) {
25+
const decodedValue = lastIncompleteChunk + decoder.decode(value);
26+
const chunks = decodedValue.split('\n');
27+
lastIncompleteChunk = chunks.pop() ?? '';
2528

26-
for (const jsonChunk of jsonChunks) {
27-
const { html, consoleReplayScript = '' } = jsonChunk;
28-
controller.enqueue(encoder.encode(html));
29+
const jsonChunks = chunks
30+
.filter(line => line.trim() !== '')
31+
.map((line) => {
32+
try {
33+
return JSON.parse(line);
34+
} catch (error) {
35+
console.error('Error parsing JSON:', line, error);
36+
throw error;
37+
}
38+
});
2939

30-
const replayConsoleCode = consoleReplayScript.trim().replace(/^<script.*>/, '').replace(/<\/script>$/, '');
31-
if (replayConsoleCode?.trim() !== '') {
32-
const scriptElement = document.createElement('script');
33-
scriptElement.textContent = replayConsoleCode;
34-
document.body.appendChild(scriptElement);
40+
for (const jsonChunk of jsonChunks) {
41+
handleJsonChunk(jsonChunk);
3542
}
43+
} else {
44+
handleJsonChunk(value);
3645
}
3746

3847
// eslint-disable-next-line no-await-in-loop

node_package/src/types/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export type RailsContext = {
3030
pathname: string;
3131
search: string | null;
3232
httpAcceptLanguage: string;
33+
rscPayloadGenerationUrl: string;
3334
} & ({
3435
serverSide: false;
35-
rscPayloadGenerationUrl: string;
3636
} | {
3737
serverSide: true;
3838
// These parameters are passed from React on Rails Pro to the node renderer.

0 commit comments

Comments
 (0)