|
| 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