Skip to content

Commit 63d86d8

Browse files
initialize the rsc payload array in a sync manner when the generate rsc function is called to avoid hydration race conditions
1 parent 577b392 commit 63d86d8

File tree

5 files changed

+50
-53
lines changed

5 files changed

+50
-53
lines changed

node_package/src/RSCPayloadGenerator.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ const mapRailsContextToRSCPayloadStreams = new Map<RailsContext, RSCPayloadStrea
1313

1414
const rscPayloadCallbacks = new Map<RailsContext, Array<RSCPayloadCallback>>();
1515

16+
// The RSC payload callbacks must be executed synchronously to maintain proper hydration timing.
17+
// This ensures that the RSC payload initialization script is injected into the HTML page
18+
// before the corresponding component's HTML markup appears. This timing is critical because:
19+
// 1. Client-side components only hydrate after their HTML is present in the page
20+
// 2. The RSC payload must be available before hydration begins to prevent unnecessary refetching
21+
// 3. Using setTimeout(callback, 0) would break this synchronization and could lead to hydration issues
1622
export const onRSCPayloadGenerated = (railsContext: RailsContext, callback: RSCPayloadCallback) => {
1723
const callbacks = rscPayloadCallbacks.get(railsContext) || [];
1824
callbacks.push(callback);
@@ -51,7 +57,8 @@ export const getRSCPayloadStream = async (
5157
streams.push(streamInfo);
5258
mapRailsContextToRSCPayloadStreams.set(railsContext, streams);
5359

54-
// Notify callbacks about the new stream
60+
// Notify callbacks about the new stream in a sync manner to maintain proper hydration timing
61+
// as described in the comment above onRSCPayloadGenerated
5562
const callbacks = rscPayloadCallbacks.get(railsContext) || [];
5663
callbacks.forEach((callback) => callback(streamInfo));
5764

node_package/src/injectRSCPayload.ts

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,20 @@ function escapeScript(script: string) {
1414
return script.replace(/<!--/g, '<\\!--').replace(/<\/(script)/gi, '</\\$1');
1515
}
1616

17+
function cacheKeyJSArray(cacheKey: string) {
18+
return `(self.REACT_ON_RAILS_RSC_PAYLOADS||={})[${JSON.stringify(cacheKey)}]||=[]`;
19+
}
20+
21+
function writeScript(script: string, transform: Transform) {
22+
transform.push(`<script>${escapeScript(script)}</script>`);
23+
}
24+
25+
function initializeCacheKeyJSArray(cacheKey: string, transform: Transform) {
26+
writeScript(cacheKeyJSArray(cacheKey), transform);
27+
}
28+
1729
function writeChunk(chunk: string, transform: Transform, cacheKey: string) {
18-
const stringifiedKey = JSON.stringify(cacheKey);
19-
transform.push(
20-
`<script>${escapeScript(`((self.REACT_ON_RAILS_RSC_PAYLOADS||={})[${stringifiedKey}]||=[]).push(${chunk})`)}</script>`,
21-
);
30+
writeScript(`(${cacheKeyJSArray(cacheKey)}).push(${chunk})`, transform);
2231
}
2332

2433
export default function injectRSCPayload(
@@ -29,26 +38,36 @@ export default function injectRSCPayload(
2938
pipeableHtmlStream.pipe(htmlStream);
3039
const decoder = new TextDecoder();
3140
let rscPromise: Promise<void> | null = null;
32-
const htmlBuffer: string[] = [];
41+
const htmlBuffer: Buffer[] = [];
3342
let timeout: NodeJS.Timeout | null = null;
3443
const resultStream = new PassThrough();
3544

36-
// Start reading RSC stream immediately
3745
const startRSC = async () => {
3846
try {
3947
const rscPromises: Promise<void>[] = [];
4048

4149
ReactOnRails.onRSCPayloadGenerated?.(railsContext, (streamInfo) => {
4250
const { stream, props, componentName } = streamInfo;
4351
const cacheKey = `${componentName}-${JSON.stringify(props)}-${railsContext.componentSpecificMetadata?.renderRequestId}`;
52+
53+
// When a component requests an RSC payload, we initialize a global array to store it.
54+
// This array is injected into the HTML before the component's HTML markup.
55+
// From our tests in SuspenseHydration.test.tsx, we know that client-side components
56+
// only hydrate after their HTML is present in the page. This timing ensures that
57+
// the RSC payload array is available before hydration begins.
58+
// As a result, the component can access its RSC payload directly from the page
59+
// instead of making a separate network request.
60+
// The client-side RSCProvider actively monitors the array for new chunks, processing them as they arrive and forwarding them to the RSC payload stream, regardless of whether the array is initially empty.
61+
initializeCacheKeyJSArray(cacheKey, resultStream);
4462
rscPromises.push(
45-
// eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
46-
new Promise(async (resolve) => {
47-
for await (const chunk of stream ?? []) {
48-
const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
49-
writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey);
50-
}
51-
resolve();
63+
new Promise((resolve, reject) => {
64+
(async () => {
65+
for await (const chunk of stream ?? []) {
66+
const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
67+
writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey);
68+
}
69+
resolve();
70+
})().catch(reject);
5271
}),
5372
);
5473
});
@@ -68,15 +87,12 @@ export default function injectRSCPayload(
6887
};
6988

7089
const writeHTMLChunks = () => {
71-
for (const htmlChunk of htmlBuffer) {
72-
resultStream.push(htmlChunk);
73-
}
90+
resultStream.push(Buffer.concat(htmlBuffer));
7491
htmlBuffer.length = 0;
7592
};
7693

7794
htmlStream.on('data', (chunk: Buffer) => {
78-
const buf = decoder.decode(chunk);
79-
htmlBuffer.push(buf);
95+
htmlBuffer.push(chunk);
8096
if (timeout) {
8197
return;
8298
}

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,12 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
8888
const consoleHistory = console.history;
8989
let previouslyReplayedConsoleMessages = 0;
9090

91-
let consoleReplayTimeoutId: NodeJS.Timeout;
92-
const buildConsoleReplayChunk = () => {
93-
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
94-
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
95-
if (consoleReplayScript.length === 0) {
96-
return null;
97-
}
98-
const consoleReplayJsonChunk = JSON.stringify(createResultObject('', consoleReplayScript, renderState));
99-
return consoleReplayJsonChunk;
100-
};
101-
10291
const transformStream = new PassThrough({
103-
transform(chunk, _, callback) {
104-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
105-
const htmlChunk = chunk.toString() as string;
106-
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, '', renderState));
92+
transform(chunk: Buffer, _, callback) {
93+
const htmlChunk = chunk.toString();
94+
const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
95+
previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
96+
const jsonChunk = JSON.stringify(createResultObject(htmlChunk, consoleReplayScript, renderState));
10797
this.push(`${jsonChunk}\n`);
10898

10999
// Reset the render state to ensure that the error is not carried over to the next chunk
@@ -112,23 +102,6 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
112102
// eslint-disable-next-line no-param-reassign
113103
renderState.hasErrors = false;
114104

115-
clearTimeout(consoleReplayTimeoutId);
116-
consoleReplayTimeoutId = setTimeout(() => {
117-
const consoleReplayChunk = buildConsoleReplayChunk();
118-
if (consoleReplayChunk) {
119-
this.push(`${consoleReplayChunk}\n`);
120-
}
121-
}, 0);
122-
123-
callback();
124-
},
125-
126-
flush(callback) {
127-
clearTimeout(consoleReplayTimeoutId);
128-
const consoleReplayChunk = buildConsoleReplayChunk();
129-
if (consoleReplayChunk) {
130-
this.push(`${consoleReplayChunk}\n`);
131-
}
132105
callback();
133106
},
134107
});

node_package/src/wrapServerComponentRenderer/client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ const WrapServerComponentRenderer = (componentOrRenderFunction: ReactComponentOr
4747
throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`);
4848
}
4949
if (domNode.innerHTML) {
50-
ReactDOMClient.hydrateRoot(domNode, root);
50+
ReactDOMClient.hydrateRoot(domNode, root, { identifierPrefix: domNodeId });
5151
} else {
52-
ReactDOMClient.createRoot(domNode).render(root);
52+
ReactDOMClient.createRoot(domNode, { identifierPrefix: domNodeId }).render(root);
5353
}
5454
// Added only to satisfy the return type of RenderFunction
5555
// However, the returned value of renderFunction is not used in ReactOnRails

node_package/tests/SuspenseHydration.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const AsyncComponentContainer = ({
4545
});
4646
return (
4747
<Suspense fallback={<div>Loading...</div>}>
48+
{/* @ts-expect-error - This is a test */}
4849
<AsyncComponent promise={promise} onRendered={onAsyncComponentRendered} />
4950
</Suspense>
5051
);

0 commit comments

Comments
 (0)