Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions internal/planning/3281-decouple-props-from-js-source.md
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()`:
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.

The benchmark script lives at /tmp/bench-json-vs-js-v2.mjs which is ephemeral. Consider committing it under internal/benchmarks/ or linking to a gist so the results can be reproduced and re-run when Node/V8 versions change.

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.

The microbenchmark script is at /tmp/bench-json-vs-js-v2.mjs — a throwaway temp path. Since this plan document will live in the repo, the benchmark should too. Consider committing it to internal/planning/benchmarks/bench-json-vs-js.mjs so any contributor can re-run it and verify the numbers.


| 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):
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.

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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)
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>

[warning] 48-48: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/docs/analysis/3281-decouple-props-from-js-source.md around lines 48

  • 57, The fenced code block containing the call chain ("rails_helper.rb ...
    vm.ts: runInVM") is missing a language tag and triggers markdownlint MD040;
    update that fenced block in
    .claude/docs/analysis/3281-decouple-props-from-js-source.md by adding an
    appropriate language identifier (e.g., text) after the opening so the block becomestext and linting will pass, leaving the existing lines
    (rails_helper.rb → ... → vm.ts: runInVM) unchanged.

</details>

<!-- fingerprinting:phantom:triton:hawk -->

<!-- This is an auto-generated comment by CodeRabbit -->


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

The thread-local stash works in Puma's threaded model, but the lifecycle needs more care. If form_with_code is called more than once during a single render (e.g., an internal retry), the second call will see nil here (cleared on line 113) and send no props field — causing the Node renderer to execute the IIFE with globalThis.__rorpProps set to undefined.

The plan should guarantee that Thread.current[:ror_decoupled_props] is set immediately before the call chain that leads to form_with_code, and that the call chain cannot repeat without a fresh set. If retries are possible, either re-set the thread-local before each attempt or pass props explicitly.


# ... 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
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 approach breaks on fiber-based web servers (e.g., Falcon)

Thread.current[:ror_decoupled_props] stores the value per OS thread. Fiber-based servers like Falcon multiplex many requests over a small number of threads using fibers; a fiber running a request never inherits thread-local values set by a different fiber on the same thread, and two concurrent requests on the same thread could see each other's values. The plan should note this limitation and, if Falcon compatibility is a goal, consider Fiber[:ror_decoupled_props] (Ruby 3.0+) instead.

#{redux_stores}
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.

P2 Badge Preserve props for ExecJS fallback

When renderer_use_fallback_exec_js is enabled, NodeRenderingPool#eval_js rescues renderer failures and executes the same js_code through RubyEmbeddedJavaScript, but that path never receives the new form props field or injects context.__rorpProps. Replacing the inline props with globalThis.__rorpProps therefore makes any ExecJS fallback render pass undefined props; the plan needs either an ExecJS injection path or a fallback-safe inline source.

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
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 Thread-local silently drops props when the value is falsy

The form_with_code snippet uses if (props = Thread.current[:ror_decoupled_props]). If props_string is nil (or the thread-local was already cleared by the ensure branch before this executes), the condition is false, form["props"] is never set, and propsJson arrives at vm.ts as undefined. Because the IIFE now reads globalThis.__rorpProps instead of embedding the value, usedProps becomes undefined and the render silently produces wrong output rather than an error. Consider using an explicit nil-check (unless Thread.current[:ror_decoupled_props].nil?) and adding a defensive fallback or explicit error if __rorpProps is absent when the plan assumes it will be present.

})()
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])
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.

The thread-local is consumed (read + cleared) inside form_with_code, but the ensure cleanup in step 3 also sets it to nil. That's fine for the happy path. However, if form_with_code is called multiple times within a single render cycle (e.g., retry logic or the incremental path that builds an initial request and then later sends updates), the second call will find the thread-local already nil and silently skip the props field — causing a runtime error in the Node VM where globalThis.__rorpProps is undefined.

Consider making the consume-once behaviour explicit (raise if it was already nil when a decoupled render was expected) or document the invariant that form_with_code is called exactly once per render().

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.

The if (props = ...) guard is falsy when props_string is nil. A component rendered with no props (nil props_string) would silently skip sending the props field, causing the Node renderer to evaluate globalThis.__rorpProps as undefined and fail. Consider always sending the field:

Suggested change
if (props = Thread.current[:ror_decoupled_props])
form["props"] = Thread.current[:ror_decoupled_props] || "{}"
Thread.current[:ror_decoupled_props] = nil

Or store props_string.to_s (converts nil to "") and always send, then skip JSON.parse on empty string on the Node side.

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 decoupled props across bundle-upload retries

When the renderer replies with STATUS_SEND_BUNDLE because the VM does not have the bundle yet, both NodeRenderingPool#eval_js and StreamRequest#each_chunk call form_with_code a second time with send_bundle: true. Clearing the thread-local here means that retry sends the new globalThis.__rorpProps-based rendering request without the separate props field, so first render after a renderer restart/deploy fails or renders with undefined props. Keep the props available until the whole render attempt finishes, and only clear it from an outer ensure.

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
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 = undefined leaves the large parsed object reachable until GC; prefer delete

The finally block sets context.__rorpProps = undefined. This re-assigns the property slot but leaves the property itself present on the object, meaning the reference to the large parsed props object persists on the VM context until the next GC cycle. For a 1.1 MB payload in a high-throughput renderer, using delete context.__rorpProps removes the property entirely and allows the GC to reclaim the object sooner.


### 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 (
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.

Adding propsJson as a 4th positional param after an already-optional vmCluster is awkward — callers that want to pass propsJson without vmCluster must write runInVM(req, path, undefined, propsJson). Consider restructuring to an options object:

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
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 Incremental render path has no implementation code

The plan acknowledges that render_code_with_incremental_updates / build_initial_incremental_request "needs the same thread-local treatment" but, unlike all six files in the implementation table, provides no code snippet for it. An implementer following this plan would have a complete recipe for five paths and then hit a gap on the sixth, increasing the risk of this path being skipped or handled inconsistently. The plan should include the equivalent of the form_with_code treatment for build_initial_incremental_request, or explicitly call out that the incremental path still embeds props inline and remains unoptimized.

propsJson?: string,
) => {
// ... existing context setup ...
try {
if (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.

JSON.parse(propsJson) will throw a SyntaxError if the payload is malformed, and that exception will bubble out of runInVM uncaught. The existing try/finally block only handles context cleanup; it doesn't catch parse errors. This should be wrapped in its own try/catch with a descriptive error message (e.g. "Failed to parse decoupled props: ...") so rendering errors surface clearly instead of appearing as generic VM failures.

if (propsJson) {
  try {
    context.__rorpProps = JSON.parse(propsJson);
  } catch (e) {
    throw new Error(`Failed to parse decoupled props payload: ${(e as Error).message}`);
  }
}

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 (propsJson) is falsy for an empty string. An empty string is not valid component props JSON, but more importantly this pattern silently does nothing for edge cases. Prefer an explicit null-check and add try-catch for parse failures:

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

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.

JSON.parse(propsJson) throws a SyntaxError on malformed input (truncated HTTP body, serialization bug). This propagates uncaught out of runInVM. The plan should specify the error-handling contract: catch and re-throw with a diagnostic message (e.g., "Failed to parse props JSON for component: ...") so server-side rendering failures are debuggable rather than surfacing as an opaque Node crash.

}
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:
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.

The type of the second argument passed by generateRSCPayload needs clarification. The plan says it calls the IIFE with (componentName, propsString) — but is propsString here a JSON string or a parsed JS object?

  • If it's a JSON string, then usedProps = props gives the IIFE a string, not an object — likely a regression.
  • If it's a parsed object, the name propsString is misleading and should be propsObject in the docs.

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

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 form_with_code), build_initial_incremental_request may have a different code path for how it embeds props. This is a gap in the plan — either add a Section 3.5 with the concrete changes or open a follow-up issue and link it here so it isn't forgotten.

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

This says the incremental path "needs the same thread-local treatment" but provides no implementation. Since build_initial_incremental_request is a separate code path from form_with_code, it either needs its own thread-local stash (and server_rendering_js_code.rb's render must set it before that path too), or the plan should explain why the same Thread.current[:ror_decoupled_props] set at the top will still be alive when build_initial_incremental_request calls form_with_code. Please flesh this out or add an explicit follow-up task.

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.

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 Thread.current[:ror_decoupled_props] for the incremental path, and does build_initial_incremental_request also call form_with_code directly, or does it go through a different code path?

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
Loading