Skip to content

Commit 10885e8

Browse files
embed rsc payload inside the html page
1 parent b75e015 commit 10885e8

8 files changed

+246
-40
lines changed

lib/react_on_rails/helper.rb

Lines changed: 6 additions & 1 deletion
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

Lines changed: 49 additions & 1 deletion
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
_railsContext?: RailsContext,
54101
domNodeId?: string,
55102
) => {
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
Lines changed: 100 additions & 0 deletions
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

Lines changed: 23 additions & 2 deletions
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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@ export function consoleReplay(
5151
}
5252

5353
export default function buildConsoleReplay(
54-
customConsoleHistory: (typeof console)['history'] | undefined = undefined,
54+
customConsoleHistory: typeof console['history'] | undefined = undefined,
5555
numberOfMessagesToSkip: number = 0,
5656
): string {
57-
return RenderUtils.wrapInScriptTags(
58-
'consoleReplayLog',
59-
consoleReplay(customConsoleHistory, numberOfMessagesToSkip),
60-
);
57+
const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip);
58+
if (consoleReplayJS.length === 0) {
59+
return '';
60+
}
61+
return RenderUtils.wrapInScriptTags('consoleReplayLog', consoleReplayJS);
6162
}

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,41 @@ 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
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
93104
const htmlChunk = chunk.toString() as string;
94-
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
95-
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
105+
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, '', renderState));
106+
this.push(`${jsonChunk}\n`);
107+
108+
clearTimeout(consoleReplayTimeoutId);
109+
consoleReplayTimeoutId = setTimeout(() => {
110+
const consoleReplayChunk = buildConsoleReplayChunk();
111+
if (consoleReplayChunk) {
112+
this.push(`${consoleReplayChunk}\n`);
113+
}
114+
}, 0);
96115

97-
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
116+
callback();
117+
},
98118

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

node_package/src/transformRSCStreamAndReplayConsoleLogs.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { RSCPayloadChunk } from './types';
22

3-
export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableStream<Uint8Array>) {
3+
export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableStream<Uint8Array | RenderResult>) {
44
return new ReadableStream({
55
async start(controller) {
66
const reader = stream.getReader();
@@ -9,35 +9,41 @@ export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableS
99

1010
let lastIncompleteChunk = '';
1111
let { value, done } = await reader.read();
12-
while (!done) {
13-
const decodedValue = lastIncompleteChunk + decoder.decode(value);
14-
const chunks = decodedValue.split('\n');
15-
lastIncompleteChunk = chunks.pop() ?? '';
12+
13+
const handleJsonChunk = (chunk: RenderResult) => {
14+
const { html, consoleReplayScript = '' } = chunk;
15+
controller.enqueue(encoder.encode(html ?? ''));
16+
17+
const replayConsoleCode = consoleReplayScript.trim().replace(/^<script.*>/, '').replace(/<\/script>$/, '');
18+
if (replayConsoleCode?.trim() !== '') {
19+
const scriptElement = document.createElement('script');
20+
scriptElement.textContent = replayConsoleCode;
21+
document.body.appendChild(scriptElement);
22+
}
23+
};
1624

17-
const jsonChunks = chunks
18-
.filter((line) => line.trim() !== '')
19-
.map((line) => {
20-
try {
21-
return JSON.parse(line) as RSCPayloadChunk;
22-
} catch (error) {
23-
console.error('Error parsing JSON:', line, error);
24-
throw error;
25-
}
26-
});
25+
while (!done) {
26+
if (ArrayBuffer.isView(value)) {
27+
const decodedValue = lastIncompleteChunk + decoder.decode(value);
28+
const chunks = decodedValue.split('\n');
29+
lastIncompleteChunk = chunks.pop() ?? '';
2730

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

32-
const replayConsoleCode = consoleReplayScript
33-
.trim()
34-
.replace(/^<script.*>/, '')
35-
.replace(/<\/script>$/, '');
36-
if (replayConsoleCode?.trim() !== '') {
37-
const scriptElement = document.createElement('script');
38-
scriptElement.textContent = replayConsoleCode;
39-
document.body.appendChild(scriptElement);
42+
for (const jsonChunk of jsonChunks) {
43+
handleJsonChunk(jsonChunk);
4044
}
45+
} else if (value) {
46+
handleJsonChunk(value);
4147
}
4248

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

node_package/src/types/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ export type RailsContext = {
3434
pathname: string;
3535
search: string | null;
3636
httpAcceptLanguage: string;
37+
rscPayloadGenerationUrl: string;
3738
} & ({
3839
serverSide: false;
39-
rscPayloadGenerationUrl: string;
4040
} | {
4141
serverSide: true;
4242
// These parameters are passed from React on Rails Pro to the node renderer.

0 commit comments

Comments
 (0)