Skip to content

Commit b72ee92

Browse files
docs: experiment plan for decoupling props from JS source (#3281) (#3297)
## Summary - Documents the experiment and implementation plan for issue #3281: sending component props as a separate JSON field to the Node renderer instead of embedding them as JS object literals in the IIFE source string - Includes microbenchmark results (JSON.parse ~1.8x faster than V8's JS parser for 1.1 MB payloads), end-to-end benchmark results (~18 ms / 14% improvement on mega benchmark), and the exact code changes needed across 6 files - No code changes — plan only, to be implemented in a follow-up PR ## Context V8 cannot cache the compiled IIFE across requests because `domNodeId` is a random UUID per render, making every source string unique. This means V8 re-parses the full source (including large props) from scratch on every request. By decoupling props and using `JSON.parse()` on the Node side, we leverage V8's optimized JSON parser. ### Key findings | Metric | Value | |--------|-------| | Microbenchmark: JS object literal parse (1.1 MB, unique source) | 11.86 ms | | Microbenchmark: JSON.parse (1.1 MB) | 6.56 ms | | End-to-end: mega_benchmark_traditional baseline | 132.4 ms | | End-to-end: mega_benchmark_traditional experiment | 113.9 ms | | **End-to-end improvement** | **18.5 ms (14%)** | ## Test plan - [ ] Review the plan document at `.claude/docs/analysis/3281-decouple-props-from-js-source.md` - [ ] Validate the proposed changes cover all render paths (traditional, streaming, incremental, RSC) - [ ] Confirm RSC compatibility analysis is correct 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a planning document detailing a proposal to decouple large React component props from embedded server-side JS, including benchmark results, performance analysis, and a staged implementation and verification plan to improve server-side rendering efficiency. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/shakacode/react_on_rails/pull/3297) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cf1a909 commit b72ee92

1 file changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Plan: Decouple Props from JS Source String (Issue #3281)
2+
3+
## Problem Statement
4+
5+
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.
6+
7+
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.
8+
9+
## V8 Compile Cache Discovery
10+
11+
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.
12+
13+
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.
14+
15+
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.
16+
17+
## Hypothesis
18+
19+
`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.
20+
21+
## Microbenchmark Results
22+
23+
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()`:
24+
25+
| Method | Mean (ms) | Notes |
26+
| ------------------------------------------ | --------- | ---------------------------------------------------- |
27+
| JS object literal (unique source per call) | 11.86 | Simulates real-world: unique `domNodeId` per request |
28+
| `JSON.parse()` | 6.56 | V8's optimized JSON parser |
29+
30+
**JSON.parse is ~1.8x faster** (~5.3 ms savings per request for 1.1 MB payloads).
31+
32+
> **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 `domNodeId` changes every request.
33+
34+
## End-to-End Benchmark Results
35+
36+
Benchmarked the full React on Rails Pro rendering pipeline (30 runs, 15 warmup, Rails + Node renderer, mega benchmark page with ~1.1 MB props):
37+
38+
| Endpoint | Baseline (ms) | Experiment (ms) | Diff (ms) | Improvement |
39+
| ----------------------------- | ------------- | --------------- | --------- | ----------- |
40+
| `/mega_benchmark_traditional` | 132.4 | 113.9 | -18.5 | 14.0% |
41+
42+
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).
43+
44+
## Architecture: Current Render Path
45+
46+
The production render path for server-side rendering:
47+
48+
```
49+
rails_helper.rb (react_component / stream_react_component)
50+
→ ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code
51+
→ ReactOnRailsPro::ServerRenderingJsCode.render (builds IIFE with embedded props)
52+
→ ReactOnRails::ServerRenderingPool#exec_server_render_js
53+
→ ReactOnRailsPro::Request#form_with_code (HTTP POST to Node renderer)
54+
→ Node: worker.ts route handler
55+
→ handleRenderRequest.ts
56+
→ vm.ts: runInVM (vm.runInContext with the IIFE)
57+
```
58+
59+
**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`.
60+
61+
## Implementation Plan
62+
63+
### 1. Ruby: `react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb`
64+
65+
In the `render` method:
66+
67+
- Stash the props string in a thread-local variable for the downstream `Request` layer
68+
- Replace the inline props embedding with a read from `globalThis.__rorpProps`
69+
- Keep the IIFE parameter fallback for RSC `generateRSCPayload` re-invocations
70+
71+
```ruby
72+
def render(props_string, rails_context, redux_stores, react_component_name, render_options)
73+
Thread.current[:ror_decoupled_props] = props_string
74+
75+
# ... existing render_function_name and rsc_params logic unchanged ...
76+
77+
<<-JS
78+
(function(componentName = #{react_component_name.to_json}, props = undefined) {
79+
var railsContext = #{rails_context};
80+
#{rsc_params}
81+
#{generate_rsc_payload_js_function(render_options)}
82+
#{ssr_pre_hook_js}
83+
#{redux_stores}
84+
var usedProps = typeof props === 'undefined' ? globalThis.__rorpProps : props;
85+
#{async_props_setup_js(render_options)}
86+
return ReactOnRails[#{render_function_name}]({
87+
name: componentName,
88+
domNodeId: #{render_options.dom_id.to_json},
89+
props: usedProps,
90+
trace: #{render_options.trace},
91+
railsContext: railsContext,
92+
throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors},
93+
renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises},
94+
generateRSCPayload: typeof generateRSCPayload !== 'undefined' ? generateRSCPayload : undefined,
95+
});
96+
})()
97+
JS
98+
end
99+
```
100+
101+
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.
102+
103+
### 2. Ruby: `react_on_rails_pro/lib/react_on_rails_pro/request.rb`
104+
105+
In `form_with_code`, pick up the thread-local and send props as a separate form field:
106+
107+
```ruby
108+
def form_with_code(js_code, send_bundle)
109+
form = common_form_data
110+
form["renderingRequest"] = js_code
111+
if (props = Thread.current[:ror_decoupled_props])
112+
form["props"] = props
113+
Thread.current[:ror_decoupled_props] = nil
114+
end
115+
populate_form_with_bundle_and_assets(form, check_bundle: false) if send_bundle
116+
form
117+
end
118+
```
119+
120+
### 3. Ruby: `react_on_rails_pro/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb`
121+
122+
Add cleanup in an `ensure` block to prevent thread-local leaks:
123+
124+
```ruby
125+
ensure
126+
Thread.current[:ror_decoupled_props] = nil
127+
```
128+
129+
### 4. Node: `packages/react-on-rails-pro-node-renderer/src/worker.ts`
130+
131+
In the `/bundles/:bundleTimestamp/render/:renderRequestDigest` route handler, extract `body.props`:
132+
133+
```typescript
134+
const { renderingRequest, props: propsJson } = body as Record<string, unknown>;
135+
// ... pass to handleRenderRequest:
136+
propsJson: typeof propsJson === 'string' ? propsJson : undefined,
137+
```
138+
139+
### 5. Node: `packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts`
140+
141+
Add `propsJson?: string` to the params type and thread it through both `prepareResult` calls (regular and streaming paths):
142+
143+
```typescript
144+
interface HandleRenderRequestParams {
145+
// ... existing fields ...
146+
propsJson?: string;
147+
}
148+
149+
// Thread to prepareResult → executionContext.runInVM
150+
```
151+
152+
### 6. Node: `packages/react-on-rails-pro-node-renderer/src/worker/vm.ts`
153+
154+
In `runInVM`, accept optional `propsJson`, inject parsed props into the VM context before executing the IIFE:
155+
156+
```typescript
157+
const runInVM = async (
158+
renderingRequest: string,
159+
bundleFilePath: string,
160+
vmCluster?: typeof cluster,
161+
propsJson?: string,
162+
) => {
163+
// ... existing context setup ...
164+
try {
165+
if (propsJson) {
166+
context.__rorpProps = JSON.parse(propsJson);
167+
}
168+
return vm.runInContext(renderingRequest, context) as RenderCodeResult;
169+
} finally {
170+
context.__rorpProps = undefined;
171+
// ... existing cleanup ...
172+
}
173+
};
174+
```
175+
176+
`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).
177+
178+
## RSC Compatibility
179+
180+
The `generateRSCPayload` function modifies the IIFE's trailing `()` to pass `(componentName, propsString)` and runs on another bundle's VM context. With this change:
181+
182+
- `usedProps = typeof props === 'undefined' ? globalThis.__rorpProps : props`
183+
- When `generateRSCPayload` calls the IIFE with props, `props !== undefined`, so `usedProps = props` (the passed-in value)
184+
- `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 parameter
185+
- `railsContext` remains embedded in the IIFE (it's small, ~1-2 KB), so it works on any bundle's context without changes
186+
187+
## Streaming and Incremental Paths
188+
189+
- **Streaming render** (`render_code_as_stream`): Also calls `form_with_code`, so props will be decoupled automatically
190+
- **Incremental render** (`render_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 treatment
191+
- **RSC payload generation**: Uses `generateRSCPayload` which passes props through the IIFE parameter — no changes needed
192+
193+
## Files to Modify
194+
195+
| File | Changes |
196+
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
197+
| `react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb` | Add thread-local stash, replace inline props with `globalThis.__rorpProps` |
198+
| `react_on_rails_pro/lib/react_on_rails_pro/request.rb` | Add `props` form field from thread-local |
199+
| `react_on_rails_pro/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb` | Add `ensure` cleanup for thread-local |
200+
| `packages/react-on-rails-pro-node-renderer/src/worker.ts` | Extract `body.props`, pass to handler |
201+
| `packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts` | Thread `propsJson` through params |
202+
| `packages/react-on-rails-pro-node-renderer/src/worker/vm.ts` | Inject `__rorpProps` via `JSON.parse()` into VM context |
203+
204+
## Future Optimization: vm.Script Caching
205+
206+
With props decoupled, the IIFE source string becomes much smaller (~200 bytes of template + railsContext). A follow-up optimization could:
207+
208+
1. Cache compiled `vm.Script` objects keyed on source string hash
209+
2. Since `domNodeId` still varies per request, consider extracting it too (or making it deterministic for SSR)
210+
3. This would eliminate V8 parse+compile entirely for repeated renders of the same component
211+
212+
## Prior Experiment PRs
213+
214+
- [#3294\[EXPERIMENT\] Decouple props from JS source: pass as separate JSON field](https://github.com/shakacode/react_on_rails/pull/3294) (closed) — Prototype implementation with code changes across Ruby and Node layers. Contains the benchmark results referenced above. Closed in favor of this plan-only document.
215+
216+
## Verification Checklist
217+
218+
1. Start Rails + Node renderer, verify pages render identical HTML
219+
2. Log `renderingRequest.length` in Node renderer to confirm IIFE is ~200 bytes (vs ~1.1 MB before)
220+
3. Run end-to-end benchmarks comparing baseline vs experiment
221+
4. Test RSC rendering path (streaming + RSC payload generation)
222+
5. Test incremental rendering path

0 commit comments

Comments
 (0)