internal/planning/3281-decouple-props-from-js-source.md
React on Rails Pro embeds component props as a JavaScript object literal inside the IIFE source string sent to the Node renderer. For large components (e.g., the mega benchmark with ~1.1 MB of props JSON), this means V8 must parse the entire props payload through its JavaScript parser on every request.
Crucially, V8 cannot cache the compiled IIFE across requests because domNodeId is a random UUID generated per render, making every source string unique. This means V8 re-parses the full source (including the large props object) from scratch on every single request.
V8 has an in-memory compile cache keyed on source string identity. When the exact same source string is passed to vm.runInContext(), V8 reuses the previously compiled bytecode, skipping parse and compile entirely.
However, the React on Rails Pro rendering IIFE includes domNodeId (a random UUID per request), so every source string is unique and V8 gets zero cache hits. This was confirmed by adding MD5 hash instrumentation to vm.ts — every request produced a different hash.
This means the full cost of V8's JavaScript parser is paid on every request, and the larger the props payload, the more time is wasted.
JSON.parse() uses V8's optimized JSON parser, which is significantly faster than V8's full JavaScript parser for structured data. By sending props as a separate field and using JSON.parse() on the Node side, we should see measurable performance improvement for large payloads.
A standalone microbenchmark (/tmp/bench-json-vs-js-v2.mjs) compared parsing 1.1 MB of JSON data as a JS object literal vs. JSON.parse():
| Method | Mean (ms) | Notes |
|---|---|---|
| JS object literal (unique source per call) | 11.86 | Simulates real-world: unique domNodeId per request |
JSON.parse() | 6.56 | V8's optimized JSON parser |
JSON.parse is ~1.8x faster (~5.3 ms savings per request for 1.1 MB payloads).
Note on benchmark methodology: An initial benchmark showed no difference (~0.4 ms) because it reused the same source string across iterations, allowing V8's compile cache to kick in. The corrected benchmark uses a unique UUID per iteration to simulate the real-world behavior where
domNodeIdchanges every request.
Benchmarked the full React on Rails Pro rendering pipeline (30 runs, 15 warmup, Rails + Node renderer, mega benchmark page with ~1.1 MB props):
| Endpoint | Baseline (ms) | Experiment (ms) | Diff (ms) | Improvement |
|---|---|---|---|---|
/mega_benchmark_traditional | 132.4 | 113.9 | -18.5 | 14.0% |
The ~18 ms improvement exceeds the microbenchmark prediction (~5 ms) because the end-to-end measurement also captures reduced HTTP body size (the IIFE source string shrinks from ~1.1 MB to ~200 bytes, reducing serialization/transfer overhead to the Node renderer).
The production render path for server-side rendering:
rails_helper.rb (react_component / stream_react_component)
→ ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code
→ ReactOnRailsPro::ServerRenderingJsCode.render (builds IIFE with embedded props)
→ ReactOnRails::ServerRenderingPool#exec_server_render_js
→ ReactOnRailsPro::Request#form_with_code (HTTP POST to Node renderer)
→ Node: worker.ts route handler
→ handleRenderRequest.ts
→ vm.ts: runInVM (vm.runInContext with the IIFE)
Important: The JsCodeBuilder class (part of the capability architecture from issue #2905) is NOT yet wired into the main rendering path. The actual production path goes through ReactOnRailsPro::ServerRenderingJsCode.render.
react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rbIn the render method:
Request layerglobalThis.__rorpPropsgenerateRSCPayload re-invocationsdef render(props_string, rails_context, redux_stores, react_component_name, render_options)
Thread.current[:ror_decoupled_props] = props_string
# ... existing render_function_name and rsc_params logic unchanged ...
<<-JS
(function(componentName = #{react_component_name.to_json}, props = undefined) {
var railsContext = #{rails_context};
#{rsc_params}
#{generate_rsc_payload_js_function(render_options)}
#{ssr_pre_hook_js}
#{redux_stores}
var usedProps = typeof props === 'undefined' ? globalThis.__rorpProps : props;
#{async_props_setup_js(render_options)}
return ReactOnRails[#{render_function_name}]({
name: componentName,
domNodeId: #{render_options.dom_id.to_json},
props: usedProps,
trace: #{render_options.trace},
railsContext: railsContext,
throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors},
renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises},
generateRSCPayload: typeof generateRSCPayload !== 'undefined' ? generateRSCPayload : undefined,
});
})()
JS
end
The key change is replacing #{props_string} (which was on the old line: var usedProps = typeof props === 'undefined' ? #{props_string} : props;) with globalThis.__rorpProps. The thread-local Thread.current[:ror_decoupled_props] passes the raw JSON string to the Request layer without changing method signatures.
react_on_rails_pro/lib/react_on_rails_pro/request.rbIn form_with_code, pick up the thread-local and send props as a separate form field:
def form_with_code(js_code, send_bundle)
form = common_form_data
form["renderingRequest"] = js_code
if (props = Thread.current[:ror_decoupled_props])
form["props"] = props
Thread.current[:ror_decoupled_props] = nil
end
populate_form_with_bundle_and_assets(form, check_bundle: false) if send_bundle
form
end
react_on_rails_pro/lib/react_on_rails_pro/rendering_strategy/node_strategy.rbAdd cleanup in an ensure block to prevent thread-local leaks:
ensure
Thread.current[:ror_decoupled_props] = nil
packages/react-on-rails-pro-node-renderer/src/worker.tsIn the /bundles/:bundleTimestamp/render/:renderRequestDigest route handler, extract body.props:
const { renderingRequest, props: propsJson } = body as Record<string, unknown>;
// ... pass to handleRenderRequest:
propsJson: typeof propsJson === 'string' ? propsJson : undefined,
packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.tsAdd propsJson?: string to the params type and thread it through both prepareResult calls (regular and streaming paths):
interface HandleRenderRequestParams {
// ... existing fields ...
propsJson?: string;
}
// Thread to prepareResult → executionContext.runInVM
packages/react-on-rails-pro-node-renderer/src/worker/vm.tsIn runInVM, accept optional propsJson, inject parsed props into the VM context before executing the IIFE:
const runInVM = async (
renderingRequest: string,
bundleFilePath: string,
vmCluster?: typeof cluster,
propsJson?: string,
) => {
// ... existing context setup ...
try {
if (propsJson) {
context.__rorpProps = JSON.parse(propsJson);
}
return vm.runInContext(renderingRequest, context) as RenderCodeResult;
} finally {
context.__rorpProps = undefined;
// ... existing cleanup ...
}
};
JSON.parse(propsJson) uses V8's optimized JSON parser (~6.6 ms for 1.1 MB) instead of V8's JavaScript parser (~11.9 ms).
The generateRSCPayload function modifies the IIFE's trailing () to pass (componentName, propsString) and runs on another bundle's VM context. With this change:
usedProps = typeof props === 'undefined' ? globalThis.__rorpProps : propsgenerateRSCPayload calls the IIFE with props, props !== undefined, so usedProps = props (the passed-in value)globalThis.__rorpProps on the RSC bundle's context will be undefined, but it's never read because the RSC path always passes props through the IIFE parameterrailsContext remains embedded in the IIFE (it's small, ~1-2 KB), so it works on any bundle's context without changesrender_code_as_stream): Also calls form_with_code, so props will be decoupled automaticallyrender_code_with_incremental_updates): Uses build_initial_incremental_request which calls form_with_code with JS code that also embeds props — needs the same thread-local treatmentgenerateRSCPayload which passes props through the IIFE parameter — no changes needed| File | Changes |
|---|---|
react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb | Add thread-local stash, replace inline props with globalThis.__rorpProps |
react_on_rails_pro/lib/react_on_rails_pro/request.rb | Add props form field from thread-local |
react_on_rails_pro/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb | Add ensure cleanup for thread-local |
packages/react-on-rails-pro-node-renderer/src/worker.ts | Extract body.props, pass to handler |
packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts | Thread propsJson through params |
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts | Inject __rorpProps via JSON.parse() into VM context |
With props decoupled, the IIFE source string becomes much smaller (~200 bytes of template + railsContext). A follow-up optimization could:
vm.Script objects keyed on source string hashdomNodeId still varies per request, consider extracting it too (or making it deterministic for SSR)renderingRequest.length in Node renderer to confirm IIFE is ~200 bytes (vs ~1.1 MB before)