Skip to content

Commit 236e475

Browse files
Refactor and improve performance of the component that embed RSC payload into the html stream (#1738)
* don't send first chunk until shell is rendered * Refactor RSC payload handling in injectRSCPayload.ts to improve buffer management * Update react_on_rails dependency in Gemfile.lock from 15.0.0.alpha.2 to 15.0.0.rc.0 for improved stability and features. * Fix documentation
1 parent 122e85a commit 236e475

File tree

2 files changed

+218
-51
lines changed

2 files changed

+218
-51
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
react_on_rails (15.0.0.alpha.2)
4+
react_on_rails (15.0.0.rc.0)
55
addressable
66
connection_pool
77
execjs (~> 2.5)

node_package/src/injectRSCPayload.ts

Lines changed: 217 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PassThrough, Transform } from 'stream';
1+
import { PassThrough } from 'stream';
22
import { finished } from 'stream/promises';
33
import { createRSCPayloadKey } from './utils.ts';
44
import { RailsContextWithServerComponentCapabilities, PipeableOrReadableStream } from './types/index.ts';
@@ -19,31 +19,39 @@ function cacheKeyJSArray(cacheKey: string) {
1919
return `(self.REACT_ON_RAILS_RSC_PAYLOADS||={})[${JSON.stringify(cacheKey)}]||=[]`;
2020
}
2121

22-
function writeScript(script: string, transform: Transform) {
23-
transform.push(`<script>${escapeScript(script)}</script>`);
22+
function createScriptTag(script: string) {
23+
return `<script>${escapeScript(script)}</script>`;
2424
}
2525

26-
function initializeCacheKeyJSArray(cacheKey: string, transform: Transform) {
27-
writeScript(cacheKeyJSArray(cacheKey), transform);
26+
function createRSCPayloadInitializationScript(cacheKey: string) {
27+
return createScriptTag(cacheKeyJSArray(cacheKey));
2828
}
2929

30-
function writeChunk(chunk: string, transform: Transform, cacheKey: string) {
31-
writeScript(`(${cacheKeyJSArray(cacheKey)}).push(${chunk})`, transform);
30+
function createRSCPayloadChunk(chunk: string, cacheKey: string) {
31+
return createScriptTag(`(${cacheKeyJSArray(cacheKey)}).push(${JSON.stringify(chunk)})`);
3232
}
3333

3434
/**
3535
* Embeds RSC payloads into the HTML stream for optimal hydration.
3636
*
37-
* This function:
38-
* 1. Creates a result stream for the combined HTML + RSC payloads
39-
* 2. Listens for RSC payload generation via onRSCPayloadGenerated
40-
* 3. Initializes global arrays for each payload BEFORE component HTML
41-
* 4. Writes each payload chunk as a script tag that pushes to the array
42-
* 5. Passes HTML through to the result stream
37+
* This function implements a sophisticated buffer management system that coordinates
38+
* three different data sources and streams them in a specific order.
4339
*
44-
* The timing of array initialization is critical - it must occur before the
45-
* component's HTML to ensure the array exists when client hydration begins.
46-
* This prevents unnecessary HTTP requests during hydration.
40+
* BUFFER MANAGEMENT STRATEGY:
41+
* - Three separate buffer arrays collect data from different sources
42+
* - A scheduled flush mechanism combines and sends data in coordinated chunks
43+
* - Streaming only begins after receiving the first HTML chunk
44+
* - Each output chunk maintains a specific data order for proper hydration
45+
*
46+
* TIMING CONSTRAINTS:
47+
* - RSC payload initialization must occur BEFORE component HTML
48+
* - First output chunk MUST contain HTML data
49+
* - Subsequent chunks can contain any combination of the three data types
50+
*
51+
* HYDRATION OPTIMIZATION:
52+
* - RSC payloads are embedded directly in the HTML stream
53+
* - Client components can access RSC data immediately without additional requests
54+
* - Global arrays are initialized before component HTML to ensure availability
4755
*
4856
* @param pipeableHtmlStream - HTML stream from React's renderToPipeableStream
4957
* @param railsContext - Context for the current request
@@ -57,10 +65,143 @@ export default function injectRSCPayload(
5765
pipeableHtmlStream.pipe(htmlStream);
5866
const decoder = new TextDecoder();
5967
let rscPromise: Promise<void> | null = null;
60-
const htmlBuffer: Buffer[] = [];
61-
let timeout: NodeJS.Timeout | null = null;
68+
69+
// ========================================
70+
// BUFFER ARRAYS - Three data sources
71+
// ========================================
72+
73+
/**
74+
* Buffer for RSC payload array initialization scripts.
75+
* These scripts create global JavaScript arrays that will store RSC payload chunks.
76+
* CRITICAL: Must be sent BEFORE the corresponding component HTML to ensure
77+
* the arrays exist when client-side hydration begins.
78+
*/
79+
const rscInitializationBuffers: Buffer[] = [];
80+
81+
/**
82+
* Buffer for HTML chunks from the React rendering stream.
83+
* Contains the actual component markup that will be displayed to users.
84+
* CONSTRAINT: The first output chunk must contain HTML data to begin streaming.
85+
*/
86+
const htmlBuffers: Buffer[] = [];
87+
88+
/**
89+
* Buffer for RSC payload chunk scripts.
90+
* These scripts push actual RSC data into the previously initialized global arrays.
91+
* Can be sent after the component HTML since the arrays already exist.
92+
*/
93+
const rscPayloadBuffers: Buffer[] = [];
94+
95+
// ========================================
96+
// FLUSH SCHEDULING SYSTEM
97+
// ========================================
98+
99+
let flushTimeout: NodeJS.Timeout | null = null;
62100
const resultStream = new PassThrough();
101+
let hasReceivedFirstHtmlChunk = false;
102+
103+
/**
104+
* Combines all buffered data into a single chunk and sends it to the result stream.
105+
*
106+
* FLUSH BEHAVIOR:
107+
* - Only starts streaming after receiving the first HTML chunk
108+
* - Combines data in a specific order: RSC initialization → HTML → RSC payloads
109+
* - Clears all buffers after flushing to prevent memory leaks
110+
* - Uses efficient buffer allocation based on total size calculation
111+
*
112+
* OUTPUT CHUNK STRUCTURE:
113+
* [RSC Array Initialization Scripts][HTML Content][RSC Payload Scripts]
114+
*/
115+
const flush = () => {
116+
// STREAMING CONSTRAINT: Don't start until we have HTML content
117+
// This ensures the first chunk always contains HTML, which is required
118+
// for proper page rendering and prevents empty initial chunks
119+
if (!hasReceivedFirstHtmlChunk && htmlBuffers.length === 0) {
120+
flushTimeout = null;
121+
return;
122+
}
123+
124+
// Calculate total buffer size for efficient memory allocation
125+
const rscInitializationSize = rscInitializationBuffers.reduce((sum, buf) => sum + buf.length, 0);
126+
const htmlSize = htmlBuffers.reduce((sum, buf) => sum + buf.length, 0);
127+
const rscPayloadSize = rscPayloadBuffers.reduce((sum, buf) => sum + buf.length, 0);
128+
const totalSize = rscInitializationSize + htmlSize + rscPayloadSize;
129+
130+
// Skip flush if no data is buffered
131+
if (totalSize === 0) {
132+
flushTimeout = null;
133+
return;
134+
}
135+
136+
// Create single buffer with exact size needed (no reallocation)
137+
const combinedBuffer = Buffer.allocUnsafe(totalSize);
138+
let offset = 0;
139+
140+
// COPY ORDER IS CRITICAL - matches hydration requirements:
141+
142+
// 1. RSC Payload array initialization scripts FIRST
143+
// These must execute before HTML to create the global arrays
144+
for (const buffer of rscInitializationBuffers) {
145+
buffer.copy(combinedBuffer, offset);
146+
offset += buffer.length;
147+
}
148+
149+
// 2. HTML chunks SECOND
150+
// Component markup that references the initialized arrays
151+
for (const buffer of htmlBuffers) {
152+
buffer.copy(combinedBuffer, offset);
153+
offset += buffer.length;
154+
}
155+
156+
// 3. RSC payload chunk scripts LAST
157+
// Data pushed into the already-existing arrays
158+
for (const buffer of rscPayloadBuffers) {
159+
buffer.copy(combinedBuffer, offset);
160+
offset += buffer.length;
161+
}
162+
163+
// Send combined chunk to output stream
164+
resultStream.push(combinedBuffer);
63165

166+
// Clear all buffers to free memory and prepare for next flush cycle
167+
rscInitializationBuffers.length = 0;
168+
htmlBuffers.length = 0;
169+
rscPayloadBuffers.length = 0;
170+
171+
flushTimeout = null;
172+
};
173+
174+
/**
175+
* Schedules a flush operation using setTimeout to batch multiple data arrivals.
176+
*
177+
* SCHEDULING STRATEGY:
178+
* - Uses setTimeout(flush, 0) to defer flush until the next event loop tick
179+
* - Batches multiple rapid data arrivals into single output chunks
180+
* - Provides optimal balance between latency and chunk efficiency
181+
*/
182+
const scheduleFlush = () => {
183+
if (flushTimeout) {
184+
return;
185+
}
186+
187+
flushTimeout = setTimeout(flush, 0);
188+
};
189+
190+
/**
191+
* Initializes RSC payload streaming and handles component registration.
192+
*
193+
* RSC WORKFLOW:
194+
* 1. Components request RSC payloads via onRSCPayloadGenerated callback
195+
* 2. For each component, we immediately create a global array initialization script
196+
* 3. We then stream RSC payload chunks as they become available
197+
* 4. Each chunk is converted to a script that pushes data to the global array
198+
*
199+
* TIMING GUARANTEE:
200+
* - Array initialization scripts are buffered immediately when requested
201+
* - HTML rendering proceeds independently
202+
* - When HTML flushes, initialization scripts are sent first
203+
* - This ensures arrays exist before component hydration begins
204+
*/
64205
const startRSC = async () => {
65206
try {
66207
const rscPromises: Promise<void>[] = [];
@@ -77,67 +218,93 @@ export default function injectRSCPayload(
77218
const { stream, props, componentName } = streamInfo;
78219
const cacheKey = createRSCPayloadKey(componentName, props, railsContext);
79220

80-
// When a component requests an RSC payload, we initialize a global array to store it.
81-
// This array is injected into the HTML before the component's HTML markup.
82-
// From our tests in SuspenseHydration.test.tsx, we know that client-side components
83-
// only hydrate after their HTML is present in the page. This timing ensures that
84-
// the RSC payload array is available before hydration begins.
85-
// As a result, the component can access its RSC payload directly from the page
86-
// instead of making a separate network request.
87-
// 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.
88-
initializeCacheKeyJSArray(cacheKey, resultStream);
221+
// CRITICAL TIMING: Initialize global array IMMEDIATELY when component requests RSC
222+
// This ensures the array exists before the component's HTML is rendered and sent.
223+
// Client-side hydration depends on this array being present in the page.
224+
//
225+
// The initialization script creates: (self.REACT_ON_RAILS_RSC_PAYLOADS||={})[cacheKey]||=[]
226+
// This creates a global array that the client-side RSCProvider monitors for new chunks.
227+
const initializationScript = createRSCPayloadInitializationScript(cacheKey);
228+
rscInitializationBuffers.push(Buffer.from(initializationScript));
229+
230+
// Process RSC payload stream asynchronously
89231
rscPromises.push(
90232
(async () => {
91233
for await (const chunk of stream ?? []) {
92234
const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
93-
writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey);
235+
const payloadScript = createRSCPayloadChunk(decodedChunk, cacheKey);
236+
rscPayloadBuffers.push(Buffer.from(payloadScript));
237+
scheduleFlush();
94238
}
95239
})(),
96240
);
97241
});
98242

243+
// Wait for HTML stream to complete, then wait for all RSC promises
99244
await finished(htmlStream).then(() => Promise.all(rscPromises));
100245
} catch (err) {
101246
resultStream.emit('error', err);
102247
}
103248
};
104249

105-
const writeHTMLChunks = () => {
106-
resultStream.push(Buffer.concat(htmlBuffer));
107-
htmlBuffer.length = 0;
108-
};
250+
// ========================================
251+
// EVENT HANDLERS - Coordinate the three data sources
252+
// ========================================
109253

254+
/**
255+
* HTML data handler - receives chunks from React's rendering stream.
256+
*
257+
* RESPONSIBILITIES:
258+
* - Buffer HTML chunks for coordinated flushing
259+
* - Track when first HTML chunk arrives (enables streaming)
260+
* - Initialize RSC processing on first HTML data
261+
* - Schedule flush to send combined data
262+
*/
110263
htmlStream.on('data', (chunk: Buffer) => {
111-
htmlBuffer.push(chunk);
112-
if (timeout) {
113-
return;
264+
htmlBuffers.push(chunk);
265+
hasReceivedFirstHtmlChunk = true;
266+
267+
if (!rscPromise) {
268+
rscPromise = startRSC();
114269
}
115270

116-
timeout = setTimeout(() => {
117-
if (!rscPromise) {
118-
rscPromise = startRSC();
119-
}
120-
writeHTMLChunks();
121-
timeout = null;
122-
}, 0);
271+
scheduleFlush();
123272
});
124273

274+
/**
275+
* Error propagation from HTML stream to result stream.
276+
*/
125277
htmlStream.on('error', (err) => {
126278
resultStream.emit('error', err);
127279
});
128280

281+
/**
282+
* HTML stream completion handler.
283+
*
284+
* CLEANUP RESPONSIBILITIES:
285+
* - Cancel any pending flush timeout
286+
* - Perform final flush to send remaining buffered data
287+
* - Wait for RSC processing to complete
288+
* - Clean up RSC payload streams
289+
* - Close result stream
290+
*/
129291
htmlStream.on('end', () => {
130-
if (timeout) {
131-
clearTimeout(timeout);
132-
}
292+
const cleanup = () => {
293+
if (flushTimeout) {
294+
clearTimeout(flushTimeout);
295+
}
296+
297+
flush();
298+
resultStream.end();
299+
};
300+
133301
if (!rscPromise) {
134-
rscPromise = startRSC();
302+
cleanup();
303+
return;
135304
}
136-
writeHTMLChunks();
305+
137306
rscPromise
138-
.then(() => {
139-
resultStream.end();
140-
})
307+
.then(cleanup)
141308
.finally(() => {
142309
if (!ReactOnRails.clearRSCPayloadStreams) {
143310
console.error('ReactOnRails Error: clearRSCPayloadStreams is not a function');

0 commit comments

Comments
 (0)