Skip to content

Commit 50bf8a1

Browse files
Refactor generateRSCPayload from global to parameter
Fix data leakage issue by passing generateRSCPayload as a parameter instead of defining it as a global function in the VM context. Changes: - Changed globalThis.generateRSCPayload to local const in server_rendering_js_code.rb - Pass generateRSCPayload as parameter to ReactOnRails rendering functions - Update RSCRequestTracker to accept generateRSCPayload in constructor - Update streamingUtils to extract and pass generateRSCPayload from options - Add GenerateRSCPayloadFunction type and add to Params interface - Update test fixtures to reflect new local function pattern This prevents concurrent requests from sharing state and eliminates potential race conditions where one request could use another's context. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 6364abd commit 50bf8a1

File tree

6 files changed

+41
-39
lines changed

6 files changed

+41
-39
lines changed

packages/react-on-rails-pro/src/RSCRequestTracker.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,10 @@ import {
1717
RSCPayloadStreamInfo,
1818
RSCPayloadCallback,
1919
RailsContextWithServerComponentMetadata,
20+
GenerateRSCPayloadFunction,
2021
} from 'react-on-rails/types';
2122
import { extractErrorMessage } from './utils.ts';
2223

23-
/**
24-
* Global function provided by React on Rails Pro for generating RSC payloads.
25-
*
26-
* This function is injected into the global scope during server-side rendering
27-
* by the RORP rendering request. It handles the actual generation of React Server
28-
* Component payloads on the server side.
29-
*
30-
* @see https://github.com/shakacode/react_on_rails_pro/blob/master/lib/react_on_rails_pro/server_rendering_js_code.rb
31-
*/
32-
declare global {
33-
function generateRSCPayload(
34-
componentName: string,
35-
props: unknown,
36-
railsContext: RailsContextWithServerComponentMetadata,
37-
): Promise<NodeJS.ReadableStream>;
38-
}
39-
4024
/**
4125
* RSC Request Tracker - manages RSC payload generation and tracking for a single request.
4226
*
@@ -52,8 +36,14 @@ class RSCRequestTracker {
5236

5337
private railsContext: RailsContextWithServerComponentMetadata;
5438

55-
constructor(railsContext: RailsContextWithServerComponentMetadata) {
39+
private generateRSCPayload?: GenerateRSCPayloadFunction;
40+
41+
constructor(
42+
railsContext: RailsContextWithServerComponentMetadata,
43+
generateRSCPayload?: GenerateRSCPayloadFunction,
44+
) {
5645
this.railsContext = railsContext;
46+
this.generateRSCPayload = generateRSCPayload;
5747
}
5848

5949
/**
@@ -120,8 +110,8 @@ class RSCRequestTracker {
120110
* @throws Error if generateRSCPayload is not available or fails
121111
*/
122112
async getRSCPayloadStream(componentName: string, props: unknown): Promise<NodeJS.ReadableStream> {
123-
// Validate that the global generateRSCPayload function is available
124-
if (typeof generateRSCPayload !== 'function') {
113+
// Validate that the generateRSCPayload function is available
114+
if (!this.generateRSCPayload) {
125115
throw new Error(
126116
'generateRSCPayload is not defined. Please ensure that you are using at least version 4.0.0 of ' +
127117
'React on Rails Pro and the Node renderer, and that ReactOnRailsPro.configuration.enable_rsc_support ' +
@@ -130,7 +120,7 @@ class RSCRequestTracker {
130120
}
131121

132122
try {
133-
const stream = await generateRSCPayload(componentName, props, this.railsContext);
123+
const stream = await this.generateRSCPayload(componentName, props, this.railsContext);
134124

135125
// Tee stream to allow for multiple consumers:
136126
// 1. stream1 - Used by React's runtime to perform server-side rendering

packages/react-on-rails-pro/src/streamingUtils.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,19 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(
181181
renderStrategy: StreamRenderer<T, P>,
182182
handleError: (options: ErrorOptions) => PipeableOrReadableStream,
183183
): T => {
184-
const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
184+
const {
185+
name: componentName,
186+
domNodeId,
187+
trace,
188+
props,
189+
railsContext,
190+
throwJsErrors,
191+
generateRSCPayload,
192+
} = options;
185193

186194
assertRailsContextWithServerComponentMetadata(railsContext);
187195
const postSSRHookTracker = new PostSSRHookTracker();
188-
const rscRequestTracker = new RSCRequestTracker(railsContext);
196+
const rscRequestTracker = new RSCRequestTracker(railsContext, generateRSCPayload);
189197
const streamingTrackers = {
190198
postSSRHookTracker,
191199
rscRequestTracker,

packages/react-on-rails/src/types/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,18 @@ export interface RegisteredComponent {
216216

217217
export type ItemRegistrationCallback<T> = (component: T) => void;
218218

219+
export type GenerateRSCPayloadFunction = (
220+
componentName: string,
221+
props: unknown,
222+
railsContext: RailsContext,
223+
) => Promise<NodeJS.ReadableStream>;
224+
219225
interface Params {
220226
props?: Record<string, unknown>;
221227
railsContext?: RailsContext;
222228
domNodeId?: string;
223229
trace?: boolean;
230+
generateRSCPayload?: GenerateRSCPayloadFunction;
224231
}
225232

226233
export interface RenderParams extends Params {

react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,11 @@ def generate_rsc_payload_js_function(render_options)
3737
rscBundleHash: '#{ReactOnRailsPro::Utils.rsc_bundle_hash}',
3838
}
3939
const runOnOtherBundle = globalThis.runOnOtherBundle;
40-
if (typeof generateRSCPayload !== 'function') {
41-
globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
42-
const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
43-
const propsString = JSON.stringify(props);
44-
const newRenderingRequest = renderingRequest.replace(/\\(\\s*\\)\\s*$/, `('${componentName}', ${propsString})`);
45-
return runOnOtherBundle(rscBundleHash, newRenderingRequest);
46-
}
40+
const generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
41+
const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
42+
const propsString = JSON.stringify(props);
43+
const newRenderingRequest = renderingRequest.replace(/\\(\\s*\\)\\s*$/, `('${componentName}', ${propsString})`);
44+
return runOnOtherBundle(rscBundleHash, newRenderingRequest);
4745
}
4846
JS
4947
end
@@ -94,6 +92,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend
9492
railsContext: railsContext,
9593
throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors},
9694
renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises},
95+
generateRSCPayload: typeof generateRSCPayload !== 'undefined' ? generateRSCPayload : undefined,
9796
});
9897
})()
9998
JS

react_on_rails_pro/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,11 @@
99
}
1010

1111
const runOnOtherBundle = globalThis.runOnOtherBundle;
12-
if (typeof generateRSCPayload !== 'function') {
13-
globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
14-
const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
15-
const propsString = JSON.stringify(props);
16-
const newRenderingRequest = renderingRequest.replace(/\(\s*\)\s*$/, `('${componentName}', ${propsString})`);
17-
return runOnOtherBundle(rscBundleHash, newRenderingRequest);
18-
}
12+
const generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) {
13+
const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters;
14+
const propsString = JSON.stringify(props);
15+
const newRenderingRequest = renderingRequest.replace(/\(\s*\)\s*$/, `('${componentName}', ${propsString})`);
16+
return runOnOtherBundle(rscBundleHash, newRenderingRequest);
1917
}
2018

2119
ReactOnRails.clearHydratedStores();
@@ -35,5 +33,6 @@
3533
railsContext: railsContext,
3634
throwJsErrors: false,
3735
renderingReturnsPromises: true,
36+
generateRSCPayload: typeof generateRSCPayload !== 'undefined' ? generateRSCPayload : undefined,
3837
});
3938
})()

react_on_rails_pro/packages/node-renderer/tests/incrementalHtmlStreaming.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,7 @@ it('incremental render html', async () => {
161161
close();
162162
});
163163

164-
// TODO: fix the problem of having a global shared `runOnOtherBundle` function
165-
it.skip('raises an error if a specific async prop is not sent', async () => {
164+
it('raises an error if a specific async prop is not sent', async () => {
166165
const { status, body } = await makeRequest();
167166
expect(body).toBe('');
168167
expect(status).toBe(200);

0 commit comments

Comments
 (0)