Skip to content

[EXPERIMENT] Decouple props from JS source: pass as separate JSON field#3294

Closed
AbanoubGhadban wants to merge 2 commits into
upcoming-v16.3.0from
experiment/3281-decouple-props
Closed

[EXPERIMENT] Decouple props from JS source: pass as separate JSON field#3294
AbanoubGhadban wants to merge 2 commits into
upcoming-v16.3.0from
experiment/3281-decouple-props

Conversation

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator

Summary

Experiment to measure the performance impact of decoupling component props from the JavaScript source string sent to the Node renderer.

Before: Props (~1.1 MB for mega benchmark) are embedded as a JS object literal inside the IIFE source string. V8 must re-parse the entire string on every request because domNodeId (a random UUID) makes each source string unique — V8's compile cache cannot help.

After: Props are sent as a separate HTTP form field. The IIFE source shrinks from ~1.1 MB to ~1.3 KB (stable template). On the Node side, JSON.parse() parses the props string using V8's optimized JSON parser instead of the JS parser.

Changes

Ruby side

  • ServerRenderingJsCode#render — Set Thread.current[:ror_decoupled_props] with the props string; replace inline #{props_string} with globalThis.__rorpProps
  • Request#form_with_code — Pick up the thread-local and add a props form field to the HTTP request
  • JsCodeBuilder — Same changes for the new strategy pattern path (not yet wired in)
  • NodeStrategy#execute — Ensure cleanup of thread-local

Node side

  • worker.ts — Extract body.props from the HTTP request
  • handleRenderRequest.ts — Thread propsJson parameter through to VM execution
  • vm.ts — Inject context.__rorpProps = JSON.parse(propsJson) before vm.runInContext(), clean up in finally

RSC compatibility

The generateRSCPayload function modifies the IIFE's trailing () to pass (componentName, propsString). With the change: typeof props === 'undefined' ? globalThis.__rorpProps : props — when called via generateRSCPayload, props !== undefined so the IIFE parameter is used, not the global. This preserves backward compatibility.

Benchmark Results

Microbenchmark: V8 JS parser vs JSON.parse (corrected)

Initial microbenchmark was flawed — it passed the same source string repeatedly, which let V8 reuse compiled code (cache hit). In production, domNodeId is a random UUID on every request, making every source string unique. Corrected benchmark uses varying source per iteration:

Payload Size JS literal (varying source) JSON.parse (stable code) Speedup Savings/request
0.1 MB 1.6ms 0.4ms 78% 1.3ms
0.5 MB 9.8ms 2.0ms 79% 7.8ms
1.0 MB 19.5ms 4.3ms 78% 15.2ms
1.5 MB 31.5ms 6.6ms 79% 24.8ms

Script: /tmp/bench-json-vs-js-v2.mjs (500 warmup, 500 measured runs per size)

End-to-end VM-level measurement (1.1 MB props, n=50)

Instrumented vm.runInContext() in the Node renderer to measure actual execution time:

Metric Experiment (decoupled) Baseline (embedded) Improvement
Source string size 1,277 bytes 1,164,084 bytes 99.9% smaller
VM exec avg 18.0ms 36.4ms 18.4ms faster (50.6%)
Unique source hashes 50/50 50/50 No V8 cache reuse on either side

Key finding

Every request produces a unique JS source string (due to random domNodeId), so V8 cannot cache compiled code across requests. Decoupling props means:

  • The IIFE template (~1.3 KB) is the same every request → V8 can cache this tiny code
  • Props are parsed by JSON.parse() which is ~5x faster than V8's JS parser for large payloads
  • Net savings: ~18ms per request for 1.1 MB props at the VM level

Recommendations

  1. This optimization is worth pursuing for production. ~18ms savings per request scales linearly with props size. For apps with large SSR payloads, this is meaningful.

  2. The thread-local pattern should be replaced with proper method signatures before merging to production. It was used here to minimize changes across 4+ method layers for the experiment.

  3. Consider also decoupling railsContext — it's small (~1-2 KB) so savings would be minimal, but it would further stabilize the IIFE template for V8 caching.

  4. Consider setting random_dom_id: false (or using a fixed id:) for SSR-heavy pages. This would allow V8 to cache the compiled IIFE across requests even without this optimization — but only when props don't change between requests.

  5. The vm.Script API could provide additional gains — pre-compiling the stable IIFE template once and reusing it across requests, rather than passing source strings to vm.runInContext() each time.

Test plan

  • Verify HTML output is byte-identical between experiment and baseline (2,186,627 bytes for mega benchmark)
  • Verify props are actually sent as separate form field (instrumented HTTP handler)
  • Verify JS source shrinks from ~1.1 MB to ~1.3 KB
  • Verify unique source hashes per request (no V8 cache reuse)
  • Run A/B benchmark with instrumented VM execution times

🤖 Generated with Claude Code

AbanoubGhadban and others added 2 commits May 13, 2026 16:49
Props (up to 1.1 MB for mega benchmark) are no longer embedded as a JS
literal in the IIFE source string. Instead they travel as a separate
`props` form field and are injected into the VM context via JSON.parse,
which uses V8's optimized JSON parser instead of its JavaScript parser.

Ruby side: JsCodeBuilder reads props from globalThis.__rorpProps (with
fallback to IIFE param for RSC re-invocations). Thread-local carries
the props string to Request.form_with_code. NodeStrategy.ensure cleans
up the thread-local on all code paths.

Node side: worker.ts extracts body.props, threads it through
handleRenderRequest to vm.ts runInVM, which does
context.__rorpProps = JSON.parse(propsJson) inside the try block
and cleans up in finally, matching the existing pattern for
renderingRequest/sharedExecutionContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The initial commit modified JsCodeBuilder, which is part of the new
strategy pattern (issue #2905) not yet wired into the main rendering
path. The actual production path uses ServerRenderingJsCode#render.

- Set thread-local in ServerRenderingJsCode#render (the real entry point)
- Change props embedding from inline `#{props_string}` to `globalThis.__rorpProps`
- Remove debug instrumentation from request.rb

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 94268851-bff5-442b-b4e9-74ac88c40075

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch experiment/3281-decouple-props

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bbb03b6c26

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

form["renderingRequest"] = js_code
if (props = Thread.current[:ror_decoupled_props])
form["props"] = props
Thread.current[:ror_decoupled_props] = nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve props across bundle-upload retries

When the renderer returns STATUS_SEND_BUNDLE, NodeRenderingPool.eval_js retries the same js_code with send_bundle: true instead of regenerating it, but this line clears the only saved props after building the first form. On a cold renderer or after a bundle hash change, the retry uploads the bundle without a props field, so the JS now runs with globalThis.__rorpProps undefined and renders with missing props. Keep the props available for every retry attempt, or pass them explicitly rather than consuming the thread-local here.

Useful? React with 👍 / 👎.

#{ssr_pre_hook_js}
#{redux_stores}
var usedProps = typeof props === 'undefined' ? #{props_string} : props;
var usedProps = typeof props === 'undefined' ? globalThis.__rorpProps : props;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep props available outside the normal Node form path

This replacement makes every Pro render request depend on the Node VM injecting __rorpProps, but several supported paths never do that injection: Pro configured with server_renderer = "ExecJS" or the ExecJS fallback executes this JS directly, and the incremental-render NDJSON path builds its initial chunk without a props field. In those contexts, components receive undefined props instead of the serialized props_string; either keep inline props for non-Node-form transports or teach those transports to provide __rorpProps too.

Useful? React with 👍 / 👎.

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Code Review: [EXPERIMENT] Decouple props from JS source

The performance motivation is solid — the benchmark results (50% reduction in VM execution time for large payloads) are compelling. The implementation is clearly labeled as experimental, and the caveats in the PR description are honest. Here are the issues to resolve before this could land in production.


🔴 Bugs

1. Incremental rendering path is broken

build_initial_incremental_request (used by render_code_with_incremental_updates) never reads Thread.current[:ror_decoupled_props] and never adds a props field to the form. So for async-props renders, the node renderer receives no propsJson, context.__rorpProps is never set, and usedProps becomes undefined. The component renders with no props and there is no error.

2. runOnOtherBundle drops propsJson

When generateRSCPayload invokes runOnOtherBundle, it calls runInVM(newRenderingRequest, otherBundleFilePath, vmCluster) — no propsJson argument. This is safe today because generateRSCPayload embeds props directly into the IIFE call (props !== undefined), but it means any future call path through runOnOtherBundle that relies on globalThis.__rorpProps will silently receive undefined props. The omission should at minimum be a documented invariant (or the signature should be fixed to forward propsJson).

3. JSON.parse(propsJson) is unguarded

If propsJson ever contains invalid JSON (bug in caller, encoding issue, truncated request body), JSON.parse throws inside the try block. There is no catch, so the error propagates to the outer VM harness as an opaque rendering failure — no helpful message indicating the props were the problem.


🟡 Design concerns

4. Silent failure when thread-local is not set

If Thread.current[:ror_decoupled_props] is nil at the point form_with_code runs (race through a different code path, double-clearing, future refactor), the props field is simply omitted from the form, the node renderer gets propsJson = undefined, and the component renders with undefined props. No exception, no log line. This should at minimum assert or raise when the field is expected but missing.

5. Double-clearing of thread-local

form_with_code clears Thread.current[:ror_decoupled_props] = nil after consuming it. NodeStrategy#execute also clears it in ensure. That's harmless but creates confusion about which site is authoritative for cleanup. Pick one (the ensure is safer; the eager clear in form_with_code could be removed).


🔵 Minor

6. Type cast in worker.ts

const { renderingRequest, props: propsJson } = body as Record<string, unknown>;

body is already validated via isValidRenderingRequest on the next line, but only for renderingRequest. Casting to Record<string, unknown> is fine here, just worth noting that propsJson has no validation other than the typeof propsJson === 'string' guard on the next line — which is correct and sufficient.

7. Removed explanatory comments

Two useful comments were deleted in server_rendering_js_code.rb:

  • the explanation of why domNodeId causes V8 cache misses (ironic given that this PR fixes exactly that)
  • the explanation of how generateRSCPayload replaces () with (componentName, props)

The second one in particular helps readers understand the backward-compatibility invariant that makes this whole approach safe. Consider keeping it.


Summary

The optimization is real and worth pursuing. Before this exits experiment status:

  1. Fix incremental rendering (either exclude it from the experiment scope and document the gap, or extend the props field to build_initial_incremental_request)
  2. Add a try/catch around JSON.parse(propsJson) with a descriptive error message
  3. Add a guard/assertion for the missing-thread-local case
  4. Document the runOnOtherBundle/propsJson invariant explicitly in the code

As noted in the PR description, replacing thread-locals with proper method signatures is the right long-term path; that work will also naturally fix issues 1 and 4.


try {
if (propsJson) {
context.__rorpProps = JSON.parse(propsJson);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling — if propsJson contains invalid JSON (encoding issue, truncated body, caller bug), this throws an opaque error with no indication that props parsing was the cause. Suggest wrapping with a descriptive catch:

Suggested change
context.__rorpProps = JSON.parse(propsJson);
try {
context.__rorpProps = JSON.parse(propsJson);
} catch (e) {
throw new Error(`Failed to parse decoupled propsJson: ${e instanceof Error ? e.message : String(e)}`);
}

@@ -397,6 +403,9 @@ export async function buildExecutionContext(
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runOnOtherBundle calls runInVM without forwarding propsJson. This is safe today because generateRSCPayload always embeds props directly in the IIFE call (so props !== undefined in the RSC bundle), but it's a silent invariant. Any future caller that relies on globalThis.__rorpProps in the RSC bundle will silently get undefined props. At minimum, add a comment here documenting the assumption; ideally forward propsJson explicitly:

Suggested change
};
context.runOnOtherBundle = (bundleTimestamp: string | number, newRenderingRequest: string) => {
const otherBundleFilePath = getRequestBundleFilePath(bundleTimestamp);
// propsJson is intentionally not forwarded: generateRSCPayload embeds props
// directly in the IIFE call, so the RSC bundle never needs __rorpProps.
return runInVM(newRenderingRequest, otherBundleFilePath, vmCluster);
};

def form_with_code(js_code, send_bundle)
form = common_form_data
form["renderingRequest"] = js_code
if (props = Thread.current[:ror_decoupled_props])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Thread.current[:ror_decoupled_props] is nil here (not set by render(), cleared early, or called from an unexpected code path), the props field is silently omitted from the form. The node renderer then sees no propsJson, skips setting context.__rorpProps, and the component renders with undefined props — no exception, no log line.

Consider adding an assertion or a warning log when the rendering request contains globalThis.__rorpProps but the thread-local is absent, so failures are observable:

# If the js_code references __rorpProps but the thread-local wasn't set,
# that's a programmer error — raise early rather than silently pass undefined props.

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.83 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.83 KB (0%)
react-on-rails/client bundled (brotli) 53.9 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.9 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.77 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.77 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.81 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.81 KB (0%)
registerServerComponent/client bundled (gzip) 127.84 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.84 KB (0%)
registerServerComponent/client bundled (brotli) 61.93 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.93 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 122.26 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.26 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.93 KB (-0.06% 🔽)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.96 KB (0%)

@greptile-apps

greptile-apps Bot commented May 13, 2026

Copy link
Copy Markdown

Greptile Summary

This experimental PR decouples React component props from the JavaScript IIFE source string sent to the Node renderer, sending them as a separate HTTP form field (props) and injecting them via JSON.parse into the VM context as globalThis.__rorpProps. The change shrinks the source string from ~1.1 MB to ~1.3 KB per request and replaces V8's JS parser with its faster JSON parser for props, yielding a measured ~18 ms savings per request at 1.1 MB payload size.

  • Ruby side: ServerRenderingJsCode#render and JsCodeBuilder#build set Thread.current[:ror_decoupled_props]; Request#form_with_code picks up the thread-local and attaches it as a props field; NodeStrategy#execute adds an ensure cleanup for the strategy-pattern path.
  • Node side: worker.ts extracts body.props, plumbs propsJson through handleRenderRequest and prepareResult, and vm.ts injects context.__rorpProps = JSON.parse(propsJson) before vm.runInContext, cleaning up in finally.
  • RSC compatibility: The generateRSCPayload path explicitly passes props as IIFE arguments so props !== undefined in the secondary bundle call and globalThis.__rorpProps is correctly bypassed; however, the runOnOtherBundle closure does not forward propsJson, which is a latent gap if future callers rely on the global rather than embedded IIFE args.

Confidence Score: 3/5

The experiment is intentionally rough-edged and not wired into the main production path yet, but the thread-local plumbing has real gaps that would need closing before a production merge.

The runOnOtherBundle closure in vm.ts silently drops propsJson, creating a hidden contract that props must be embedded as IIFE args for any secondary bundle call — a contract that could break unnoticed during follow-up work. On the Ruby side, server_rendering_js_code.rb#render sets the thread-local with no ensure in the main (non-NodeStrategy) calling path; if an exception occurs before form_with_code runs, the stale thread-local can be consumed by a subsequent retry or the next request on the same thread. Both issues are in core rendering paths.

react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb (no ensure for thread-local cleanup in the main path) and packages/react-on-rails-pro-node-renderer/src/worker/vm.ts (runOnOtherBundle silently omits propsJson)

Important Files Changed

Filename Overview
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts Injects context.__rorpProps from propsJson before vm.runInContext and cleans up in finally; runOnOtherBundle callback silently drops propsJson — safe today because the RSC path always embeds props as IIFE args, but fragile for any future secondary render that uses globalThis.__rorpProps
react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb Sets Thread.current[:ror_decoupled_props] at the top of render and replaces inline props_string interpolation with globalThis.__rorpProps; no ensure in the main path to clean up the thread-local if an exception occurs before form_with_code is reached
react_on_rails_pro/lib/react_on_rails_pro/request.rb Reads and immediately clears Thread.current[:ror_decoupled_props] in form_with_code to attach props as a separate props form field; cleanup is one-shot so any retry of form_with_code without a preceding render call would omit props silently
react_on_rails_pro/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb Adds ensure Thread.current[:ror_decoupled_props] = nil as a safety net for the strategy-pattern path; correct and defensive
react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb Overrides build to set the thread-local before delegating to super; mirrors the server_rendering_js_code.rb changes for the strategy-pattern path; NodeStrategy#ensure covers cleanup
packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts Threads propsJson?: string parameter through prepareResult and both handleRenderRequest code paths; straightforward plumbing with no logic issues
packages/react-on-rails-pro-node-renderer/src/worker.ts Extracts props from the request body and narrows it to `string

Comments Outside Diff (1)

  1. packages/react-on-rails-pro-node-renderer/src/worker/vm.ts, line 400-403 (link)

    P1 runOnOtherBundle silently drops propsJson

    The runOnOtherBundle closure calls runInVM without forwarding propsJson. In the current generateRSCPayload flow this is safe because props are embedded as IIFE arguments in newRenderingRequest (so the props !== undefined branch is taken). However, any future caller that passes an unmodified rendering request — one whose IIFE uses globalThis.__rorpProps — will silently receive undefined props rather than a runtime error. The hidden dependency between the caller embedding props and this closure not forwarding propsJson is fragile and easy to break during follow-up work.

Reviews (1): Last reviewed commit: "Fix: decouple props in actual render pat..." | Re-trigger Greptile

Comment on lines 405 to +408
try {
if (propsJson) {
context.__rorpProps = JSON.parse(propsJson);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Using a simple truthy check if (propsJson) treats an empty string "" as "no props" and skips JSON.parse. While valid serialized props are never an empty string, an explicit != null guard documents the intent clearly and avoids potential silent failures if callers ever send an empty field.

Suggested change
try {
if (propsJson) {
context.__rorpProps = JSON.parse(propsJson);
}
try {
if (propsJson != null) {
context.__rorpProps = JSON.parse(propsJson);
}

Comment on lines 95 to +96
def render(props_string, rails_context, redux_stores, react_component_name, render_options)
Thread.current[:ror_decoupled_props] = props_string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No ensure to clean up thread-local in the main rendering path

Thread.current[:ror_decoupled_props] is set here at the very start of render. The only cleanup on the main (non-NodeStrategy) path lives inside the if (props = Thread.current[:ror_decoupled_props]) block inside form_with_code. If an exception propagates after this assignment but before form_with_code is called (e.g., a connection-level error raised inside the rendering pool before the HTTP form is built), the thread-local persists for the lifetime of the thread. On the next render on the same thread the value is overwritten, so the cross-contamination window is narrow — but if form_with_code is invoked again (e.g., a retry path in the rendering pool) without a preceding render, it will attach the stale props to the retry request silently.

Comment on lines 280 to 289
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Thread-local read inside streaming fiber may be unreachable for the render_code_as_stream path

render_code_as_stream calls form_with_code inside a StreamRequest.create block. If that block is executed in the same OS thread (e.g., as an Async fiber), the read in line 283 is safe. However, if StreamRequest.create ever defers or migrates the block to a different thread (e.g., a thread-pool-backed async executor), Thread.current[:ror_decoupled_props] would be nil in the worker thread even though it is set in the calling thread, causing props to be silently dropped. The current Async/fiber implementation appears safe, but documenting the assumption (or using a Fiber.current local instead of a thread-local) would make this more robust.

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

Closing in favor of a plan-only PR. The experiment code changes will be reimplemented based on the plan document.

AbanoubGhadban added a commit that referenced this pull request May 15, 2026
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant