Skip to content

Commit aabf2f0

Browse files
Refactor RSC payload handling in injectRSCPayload.ts to improve buffer management
1 parent 71d3a00 commit aabf2f0

File tree

1 file changed

+213
-54
lines changed

1 file changed

+213
-54
lines changed

node_package/src/injectRSCPayload.ts

Lines changed: 213 additions & 54 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);
165+
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;
63170

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,75 +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-
if (htmlBuffer.length === 0) {
107-
return;
108-
}
250+
// ========================================
251+
// EVENT HANDLERS - Coordinate the three data sources
252+
// ========================================
253+
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+
*/
263+
htmlStream.on('data', (chunk: Buffer) => {
264+
htmlBuffers.push(chunk);
265+
hasReceivedFirstHtmlChunk = true;
109266

110267
if (!rscPromise) {
111268
rscPromise = startRSC();
112269
}
113270

114-
resultStream.push(Buffer.concat(htmlBuffer));
115-
htmlBuffer.length = 0;
116-
};
117-
118-
htmlStream.on('data', (chunk: Buffer) => {
119-
htmlBuffer.push(chunk);
120-
if (timeout) {
121-
return;
122-
}
123-
124-
timeout = setTimeout(() => {
125-
writeHTMLChunks();
126-
timeout = null;
127-
}, 0);
271+
scheduleFlush();
128272
});
129273

274+
/**
275+
* Error propagation from HTML stream to result stream.
276+
*/
130277
htmlStream.on('error', (err) => {
131278
resultStream.emit('error', err);
132279
});
133280

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+
*/
134291
htmlStream.on('end', () => {
135-
if (timeout) {
136-
clearTimeout(timeout);
137-
}
138-
writeHTMLChunks();
292+
const cleanup = () => {
293+
if (flushTimeout) {
294+
clearTimeout(flushTimeout);
295+
}
139296

140-
if (!rscPromise) {
297+
flush();
141298
resultStream.end();
299+
};
300+
301+
if (!rscPromise) {
302+
cleanup();
142303
return;
143304
}
144305

145306
rscPromise
146-
.then(() => {
147-
resultStream.end();
148-
})
307+
.then(cleanup)
149308
.finally(() => {
150309
if (!ReactOnRails.clearRSCPayloadStreams) {
151310
console.error('ReactOnRails Error: clearRSCPayloadStreams is not a function');

0 commit comments

Comments
 (0)