-
-
Notifications
You must be signed in to change notification settings - Fork 632
docs: experiment plan for decoupling props from JS source (#3281) #3297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,222 @@ | ||||||||||||||||||
| # Plan: Decouple Props from JS Source String (Issue #3281) | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Problem Statement | ||||||||||||||||||
|
|
||||||||||||||||||
| 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 Compile Cache Discovery | ||||||||||||||||||
|
|
||||||||||||||||||
| 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. | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Hypothesis | ||||||||||||||||||
|
|
||||||||||||||||||
| `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. | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Microbenchmark Results | ||||||||||||||||||
|
|
||||||||||||||||||
| 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()`: | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The microbenchmark script is at |
||||||||||||||||||
|
|
||||||||||||||||||
| | 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 `domNodeId` changes every request. | ||||||||||||||||||
|
|
||||||||||||||||||
| ## End-to-End Benchmark Results | ||||||||||||||||||
|
|
||||||||||||||||||
| Benchmarked the full React on Rails Pro rendering pipeline (30 runs, 15 warmup, Rails + Node renderer, mega benchmark page with ~1.1 MB props): | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The table reports only means (132.4 ms vs 113.9 ms). With 30 runs that's a reasonable sample, but without a standard deviation it's hard to tell if the 18.5 ms delta is outside the noise floor. Please add stddev (or p95) for both baseline and experiment — e.g. "132.4 ± 8.2 ms" — so the 14% claim carries statistical weight. |
||||||||||||||||||
|
|
||||||||||||||||||
| | 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). | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Architecture: Current Render Path | ||||||||||||||||||
|
|
||||||||||||||||||
| 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) | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
Comment on lines
+48
to
+57
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a language tag to the fenced code block at Line 48. This block is missing a language identifier, which will keep tripping markdownlint (MD040). Suggested fix-```
+```text
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)Verify each finding against current code. Fix only still-valid issues, skip the In @.claude/docs/analysis/3281-decouple-props-from-js-source.md around lines 48
|
||||||||||||||||||
|
|
||||||||||||||||||
| **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`. | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Implementation Plan | ||||||||||||||||||
|
|
||||||||||||||||||
| ### 1. Ruby: `react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb` | ||||||||||||||||||
|
|
||||||||||||||||||
| In the `render` method: | ||||||||||||||||||
|
|
||||||||||||||||||
| - Stash the props string in a thread-local variable for the downstream `Request` layer | ||||||||||||||||||
| - Replace the inline props embedding with a read from `globalThis.__rorpProps` | ||||||||||||||||||
| - Keep the IIFE parameter fallback for RSC `generateRSCPayload` re-invocations | ||||||||||||||||||
|
|
||||||||||||||||||
| ```ruby | ||||||||||||||||||
| def render(props_string, rails_context, redux_stores, react_component_name, render_options) | ||||||||||||||||||
| Thread.current[:ror_decoupled_props] = props_string | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The thread-local stash works in Puma's threaded model, but the lifecycle needs more care. If The plan should guarantee that |
||||||||||||||||||
|
|
||||||||||||||||||
| # ... 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} | ||||||||||||||||||
|
Comment on lines
+80
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||
| #{redux_stores} | ||||||||||||||||||
| var usedProps = typeof props === 'undefined' ? globalThis.__rorpProps : props; | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||||||||||||||||||
| #{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, | ||||||||||||||||||
| }); | ||||||||||||||||||
|
Comment on lines
+85
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The |
||||||||||||||||||
| })() | ||||||||||||||||||
| 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. | ||||||||||||||||||
|
|
||||||||||||||||||
| ### 2. Ruby: `react_on_rails_pro/lib/react_on_rails_pro/request.rb` | ||||||||||||||||||
|
|
||||||||||||||||||
| In `form_with_code`, pick up the thread-local and send props as a separate form field: | ||||||||||||||||||
|
|
||||||||||||||||||
| ```ruby | ||||||||||||||||||
| def form_with_code(js_code, send_bundle) | ||||||||||||||||||
| form = common_form_data | ||||||||||||||||||
| form["renderingRequest"] = js_code | ||||||||||||||||||
| if (props = Thread.current[:ror_decoupled_props]) | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The thread-local is consumed (read + cleared) inside Consider making the consume-once behaviour explicit (raise if it was already nil when a decoupled render was expected) or document the invariant that
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
Or store |
||||||||||||||||||
| form["props"] = props | ||||||||||||||||||
| Thread.current[:ror_decoupled_props] = nil | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the renderer replies with Useful? React with 👍 / 👎. |
||||||||||||||||||
| end | ||||||||||||||||||
| populate_form_with_bundle_and_assets(form, check_bundle: false) if send_bundle | ||||||||||||||||||
| form | ||||||||||||||||||
| end | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
| ### 3. Ruby: `react_on_rails_pro/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb` | ||||||||||||||||||
|
|
||||||||||||||||||
| Add cleanup in an `ensure` block to prevent thread-local leaks: | ||||||||||||||||||
|
|
||||||||||||||||||
| ```ruby | ||||||||||||||||||
| ensure | ||||||||||||||||||
| Thread.current[:ror_decoupled_props] = nil | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
| ### 4. Node: `packages/react-on-rails-pro-node-renderer/src/worker.ts` | ||||||||||||||||||
|
|
||||||||||||||||||
| In the `/bundles/:bundleTimestamp/render/:renderRequestDigest` route handler, extract `body.props`: | ||||||||||||||||||
|
|
||||||||||||||||||
| ```typescript | ||||||||||||||||||
| const { renderingRequest, props: propsJson } = body as Record<string, unknown>; | ||||||||||||||||||
| // ... pass to handleRenderRequest: | ||||||||||||||||||
| propsJson: typeof propsJson === 'string' ? propsJson : undefined, | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
Comment on lines
+127
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The |
||||||||||||||||||
|
|
||||||||||||||||||
| ### 5. Node: `packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts` | ||||||||||||||||||
|
|
||||||||||||||||||
| Add `propsJson?: string` to the params type and thread it through both `prepareResult` calls (regular and streaming paths): | ||||||||||||||||||
|
|
||||||||||||||||||
| ```typescript | ||||||||||||||||||
| interface HandleRenderRequestParams { | ||||||||||||||||||
| // ... existing fields ... | ||||||||||||||||||
| propsJson?: string; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Thread to prepareResult → executionContext.runInVM | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
| ### 6. Node: `packages/react-on-rails-pro-node-renderer/src/worker/vm.ts` | ||||||||||||||||||
|
|
||||||||||||||||||
| In `runInVM`, accept optional `propsJson`, inject parsed props into the VM context before executing the IIFE: | ||||||||||||||||||
|
|
||||||||||||||||||
| ```typescript | ||||||||||||||||||
| const runInVM = async ( | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding interface RunInVMOptions {
vmCluster?: typeof cluster;
propsJson?: string;
}
const runInVM = async (
renderingRequest: string,
bundleFilePath: string,
options: RunInVMOptions = {},
) => { ... } |
||||||||||||||||||
| renderingRequest: string, | ||||||||||||||||||
| bundleFilePath: string, | ||||||||||||||||||
| vmCluster?: typeof cluster, | ||||||||||||||||||
|
Comment on lines
+157
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The plan acknowledges that |
||||||||||||||||||
| propsJson?: string, | ||||||||||||||||||
| ) => { | ||||||||||||||||||
| // ... existing context setup ... | ||||||||||||||||||
| try { | ||||||||||||||||||
| if (propsJson) { | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if (propsJson) {
try {
context.__rorpProps = JSON.parse(propsJson);
} catch (e) {
throw new Error(`Failed to parse decoupled props payload: ${(e as Error).message}`);
}
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| context.__rorpProps = JSON.parse(propsJson); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||
| } | ||||||||||||||||||
| 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). | ||||||||||||||||||
|
|
||||||||||||||||||
| ## RSC Compatibility | ||||||||||||||||||
|
|
||||||||||||||||||
| The `generateRSCPayload` function modifies the IIFE's trailing `()` to pass `(componentName, propsString)` and runs on another bundle's VM context. With this change: | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The type of the second argument passed by
Please spell out which type RSC passes so implementors don't have to reverse-engineer it from the existing source. |
||||||||||||||||||
|
|
||||||||||||||||||
| - `usedProps = typeof props === 'undefined' ? globalThis.__rorpProps : props` | ||||||||||||||||||
| - When `generateRSCPayload` 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 parameter | ||||||||||||||||||
| - `railsContext` remains embedded in the IIFE (it's small, ~1-2 KB), so it works on any bundle's context without changes | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Streaming and Incremental Paths | ||||||||||||||||||
|
|
||||||||||||||||||
| - **Streaming render** (`render_code_as_stream`): Also calls `form_with_code`, so props will be decoupled automatically | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The incremental render path is flagged as needing "the same thread-local treatment" but no code sketch is provided. Unlike the streaming path (which already flows through |
||||||||||||||||||
| - **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 | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This says the incremental path "needs the same thread-local treatment" but provides no implementation. Since
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The incremental render path is flagged as needing "the same thread-local treatment" but the implementation is left unspecified. Since this is a planning doc meant to guide the implementation PR, the gap should be filled here: which method sets Without this detail, the implementor may overlook the incremental path and ship a partial fix. |
||||||||||||||||||
| - **RSC payload generation**: Uses `generateRSCPayload` which passes props through the IIFE parameter — no changes needed | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Files to Modify | ||||||||||||||||||
|
|
||||||||||||||||||
| | 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 | | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Future Optimization: vm.Script Caching | ||||||||||||||||||
|
|
||||||||||||||||||
| With props decoupled, the IIFE source string becomes much smaller (~200 bytes of template + railsContext). A follow-up optimization could: | ||||||||||||||||||
|
|
||||||||||||||||||
| 1. Cache compiled `vm.Script` objects keyed on source string hash | ||||||||||||||||||
| 2. Since `domNodeId` still varies per request, consider extracting it too (or making it deterministic for SSR) | ||||||||||||||||||
| 3. This would eliminate V8 parse+compile entirely for repeated renders of the same component | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Prior Experiment PRs | ||||||||||||||||||
|
|
||||||||||||||||||
| - [#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. | ||||||||||||||||||
|
|
||||||||||||||||||
| ## Verification Checklist | ||||||||||||||||||
|
|
||||||||||||||||||
| 1. Start Rails + Node renderer, verify pages render identical HTML | ||||||||||||||||||
| 2. Log `renderingRequest.length` in Node renderer to confirm IIFE is ~200 bytes (vs ~1.1 MB before) | ||||||||||||||||||
| 3. Run end-to-end benchmarks comparing baseline vs experiment | ||||||||||||||||||
| 4. Test RSC rendering path (streaming + RSC payload generation) | ||||||||||||||||||
| 5. Test incremental rendering path | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The benchmark script lives at
/tmp/bench-json-vs-js-v2.mjswhich is ephemeral. Consider committing it underinternal/benchmarks/or linking to a gist so the results can be reproduced and re-run when Node/V8 versions change.