Skip to content

Add bidirectional streaming for async props (pull mode)#4048

Merged
justin808 merged 30 commits into
mainfrom
4046-bidirectional-streaming-async-props
Jun 20, 2026
Merged

Add bidirectional streaming for async props (pull mode)#4048
justin808 merged 30 commits into
mainfrom
4046-bidirectional-streaming-async-props

Conversation

@AbanoubGhadban

@AbanoubGhadban AbanoubGhadban commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements #4046: bidirectional streaming of async props where React components can lazily request props from Rails via HTTP/2 streaming, complementing the existing push model.

User-facing API

<%# Pure pull mode — all props requested lazily by React %>
<%= stream_react_component_with_async_props("Dashboard", push_props: []) do |emit|
  while (prop_name = emit.pull_requests.dequeue)
    emit.call(prop_name, fetch_prop(prop_name))
  end
end %>

<%# Mixed mode — stats pushed eagerly, rest pulled on demand %>
<%= stream_react_component_with_async_props("Dashboard", push_props: %w[stats]) do |emit|
  emit.call("stats", compute_stats)
  while (prop_name = emit.pull_requests.dequeue)
    case prop_name
    when "recommendations" then emit.call(prop_name, Recommendation.all)
    else emit.reject(prop_name, "Unknown prop: #{prop_name}")
    end
  end
end %>

Architecture

  • React side: getProp() in AsyncPropsManager emits propRequest control messages for non-push props
  • Node renderer: Injects a PassThrough stream that multiplexes HTML chunks + propRequest/renderComplete control messages
  • Rails side: StreamRequest parses control messages, routes propRequestPullRequestQueue, renderComplete → closes queue
  • Wire format: Length-prefixed chunks with messageType metadata field for control messages

Key changes

Layer File Change
TS (Pro) AsyncPropsManager.ts Pull request emission, rejectProp(), buffered requests, flushPendingPullRequests()
TS (Pro) streamingUtils.ts formatPropRequestChunk(), formatRenderCompleteChunk()
TS (Renderer) handleIncrementalRenderRequest.ts Injectable PassThrough stream, propRequest emitter, renderComplete on stream end
TS (Renderer) vm.ts Expose sharedExecutionContext on ExecutionContext type
Ruby (OSS) length_prefixed_parser.rb Route messageType control messages without HTML payload
Ruby (Pro) async_props_emitter.rb PullRequestQueue, reject(), render_complete!(), pushed props tracking
Ruby (Pro) request.rb pull_enabled flag, pullEnabled/pushProps in NDJSON, return [response, emitter] tuple
Ruby (Pro) stream_request.rb Destructure pull tuple, route control messages, safety-net render_complete!
Ruby (Pro) node_rendering_pool.rb Pass push_props to incremental render
Dummy app 5 views, 3 components, controller, routes, E2E fixtures Test pages for pure pull, mixed, and rejection scenarios

Test plan

  • Existing LengthPrefixedParser specs pass (8/8)
  • Existing node renderer tests pass (pre-existing timeout failures excluded)
  • TypeScript builds clean for react-on-rails-pro and react-on-rails-pro-node-renderer
  • Rubocop passes on all modified Ruby files
  • Mixed props page SSR renders pushed stats + pulled recommendations/relatedPosts
  • Rejection page SSR renders allowed items + error boundary for forbidden data
  • Existing streaming pages (/stream_async_components, /test_incremental_rendering) have no regressions
  • E2E tests for lazy/mixed props via Redis fixtures

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added pull-based async prop loading for incremental renders, including mixed push/pull behavior via an optional eager prop list.
    • Added async prop rejection support, including client-visible error boundaries and coordinated render-complete signaling.
  • Bug Fixes

    • Improved streaming control-message handling so control chunks aren’t misinterpreted as HTML.
  • Tests

    • Expanded end-to-end coverage for pull/Redis scenarios and prop rejection, plus updated React fixtures/components and queue/emitter unit tests.

@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 63.5 KB (0%)
react-on-rails/client bundled (gzip) (time) 63.5 KB (0%)
react-on-rails/client bundled (brotli) 54.51 KB (0%)
react-on-rails/client bundled (brotli) (time) 54.51 KB (0%)
react-on-rails-pro/client bundled (gzip) 64.27 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 64.27 KB (0%)
react-on-rails-pro/client bundled (brotli) 55.21 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 55.21 KB (0%)
registerServerComponent/client bundled (gzip) 74.43 KB (+0.01% 🔺)
registerServerComponent/client bundled (gzip) (time) 74.43 KB (0%)
registerServerComponent/client bundled (brotli) 64.04 KB (0%)
registerServerComponent/client bundled (brotli) (time) 64.04 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 67.23 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 67.23 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 57.67 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 57.67 KB (0%)

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a bidirectional "pull mode" for async props in the incremental streaming pipeline. Node-side AsyncPropsManager requests props lazily via propRequest NDJSON control messages; Rails-side AsyncPropsEmitter fulfills or rejects them via a new PullRequestQueue; renderComplete closes the queue. The feature is wired through sharedExecutionContext, StreamRequest, Request, and NodeRenderingPool, with six new test endpoints, ERB views, React components, and Playwright E2E specs.

Changes

Pull Mode Async Props Core Protocol

Layer / File(s) Summary
Length-prefixed wire protocol
packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts, react_on_rails/lib/react_on_rails/length_prefixed_parser.rb, react_on_rails/spec/lib/react_on_rails/length_prefixed_parser_spec.rb
Adds formatPropRequestChunk and formatRenderCompleteChunk Buffer formatters on the Node side for control-message NDJSON encoding. Extends LengthPrefixedParser to detect messageType-keyed chunks, yield them as metadata-only control messages, and skip HTML reconstruction. Includes parser spec coverage for control-message interleaving with HTML chunks.
VM shared execution context
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
Adds sharedExecutionContext: Map<string, unknown> to the ExecutionContext type and exposes the per-request map in buildExecutionContext's return value.
Node-side AsyncPropsManager pull/push mode
packages/react-on-rails-pro/src/AsyncPropsManager.ts, packages/react-on-rails-pro/tests/AsyncPropManager.test.ts
Adds pullRequested flag to PromiseController; extends getProp() to emit prop requests when pull mode is active and prop is not pushed; adds rejectProp(), flushPendingPullRequests(), and emitPendingPullRequests(); exports shared-context key constants; wires sharedExecutionContext into getOrCreateAsyncPropsManager; includes comprehensive test coverage for rejection and pull-request emission.
handleIncrementalRenderRequest pull mode stream injection
packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts
Extends FirstIncrementalRenderRequestChunk with pullEnabled and pushProps fields; when pull mode is active, injects a PassThrough stream, registers the prop-request emitter in sharedExecutionContext, writes renderComplete on stream end, and flushes buffered pull requests from AsyncPropsManager.
Rails-side AsyncPropsEmitter pull mode, reject, and PullRequestQueue
react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb, react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb
Extends AsyncPropsEmitter with pull_enabled: init option, @pushed_props set tracking, conditional PullRequestQueue instantiation, pull_enabled? predicate, pull_requests attr_reader, reject(prop_name, reason), render_complete!, and NDJSON reject chunk generators. Adds PullRequestQueue class wrapping Async::Queue with automatic filtering of already-pushed props, close-state tracking, and safe race-condition handling. Includes comprehensive spec coverage.
Request and NodeRenderingPool incremental-render wiring
react_on_rails_pro/lib/react_on_rails_pro/request.rb, react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb, react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb
render_code_with_incremental_updates accepts push_props:, derives pull_enabled from it, threads it through StreamRequest.create and AsyncPropsEmitter, adds pullEnabled/pushProps to the initial NDJSON payload, and returns [response, emitter] in pull mode. NodeRenderingPool reads push_props from render options and forwards it. Spec updated to stub push_props: nil.
StreamRequest control message routing
react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb, react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb
Adds pull_enabled: keyword to constructor and factory method, unpacks [stream_response, @emitter] in pull mode, refactors chunk handling into record_first_chunk and parse_and_route_chunk helpers, and dispatches propRequest/renderComplete control messages to the emitter via route_control_message. Includes specs verifying propRequest enqueuing, missing propName handling, and queue closure on parse errors.
Test React components
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyPropsComponent.tsx, MixedPropsComponent.tsx, PropErrorBoundary.client.tsx, LazyPropRejectionComponent.tsx
Adds four new TSX components: LazyPropsComponent (three lazy async Suspense sections), MixedPropsComponent (one pushed + two pulled async props), PropErrorBoundary (class error boundary with data-testid), and LazyPropRejectionComponent (allowed/forbidden async props with error boundary wrapping and AsyncList helper).
Rails test endpoints, routes, and ERB views
react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb, react_on_rails_pro/spec/dummy/config/routes.rb, react_on_rails_pro/spec/dummy/app/views/pages/lazy_props_*, mixed_props_*, rejection_props_*, react_on_rails_pro/spec/dummy/spec/controllers/pages_controller_spec.rb
Adds six controller actions for three prop scenarios (lazy, mixed, rejection) with direct and Redis-backed variants. Implements read_lazy_props_from_redis helper reading Redis streams until "end", routing "!"-prefixed keys to emitter.reject and ":"-prefixed to emitter.call. Adds six GET routes and six ERB templates wiring stream_react_component_with_async_props with push_props and pull-request dequeue loops. Includes controller spec validating Redis entry routing.
Playwright E2E fixtures and specs
react_on_rails_pro/spec/dummy/e2e-tests/fixture.ts, react_on_rails_pro/spec/dummy/e2e-tests/lazyProps.spec.ts
Extends RedisReceiverControllerFixture with rejectRedisValue(key, reason), adds lazyPropsRedisPageTest, mixedPropsRedisPageTest, and rejectionPropsRedisPageTest fixtures, and adds comprehensive Playwright specs verifying incremental lazy prop resolution, mixed push/pull mode behavior, and prop rejection error boundaries.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant Rails as Rails (StreamRequest)
  participant Emitter as AsyncPropsEmitter
  participant Node as Node Renderer
  participant APM as AsyncPropsManager
  participant React as React Component

  Browser->>Rails: HTTP GET /lazy_props_for_testing
  Rails->>Node: initial NDJSON chunk (pullEnabled: true, pushProps: [])
  Node->>React: render component
  React->>APM: getProp("users")
  APM->>Node: propRequest chunk → PassThrough stream
  Node->>Rails: propRequest control message
  Rails->>Emitter: pull_requests.enqueue("users")
  Emitter->>Rails: dequeue → "users"
  Rails->>Node: NDJSON async prop update (users data)
  Node->>APM: resolve "users" promise
  APM->>React: resolved users data
  React->>Node: React stream ends
  Node->>Rails: renderComplete control message
  Rails->>Emitter: render_complete! → pull_requests.close
  Rails->>Browser: streaming HTML complete
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • shakacode/react_on_rails#2903: Both PRs touch the same incremental-render/async-props plumbing — this PR adds pull-mode propRequest/renderComplete handling by wiring handleIncrementalRenderRequest.ts and vm.ts sharedExecutionContext into AsyncPropsManager.ts, building directly on #2903's async props and incremental-render infrastructure.
  • shakacode/react_on_rails#3320: Both PRs modify the renderer bidirectional/incremental streaming stack in react_on_rails_pro/lib/react_on_rails_pro/request.rb and stream_request.rb, so they are directly connected at the same streaming code paths.

Suggested labels

enhancement, review-needed, full-ci, P2

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main feature: adding bidirectional streaming for async props in pull mode, which aligns with the substantial implementation across multiple files and layers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 4046-bidirectional-streaming-async-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: e0f5d17b7e

ℹ️ 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".

Comment thread react_on_rails_pro/lib/react_on_rails_pro/request.rb Outdated
@AbanoubGhadban

AbanoubGhadban commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Review triage summary

Scan window: Full PR history

Addressed

# Category Item Status
1 MUST-FIX request.rb:135 — block returns [response, emitter] even in push-only mode (@Codex-[bot]) Fixed in 7f8ca32
2 MUST-FIX @pushed_props poisoned before write succeeds (@greptile-apps[bot]) Fixed in c302ec2
3 MUST-FIX Pull-mode flags not set before initial render — getProp never emits propRequest (@Codex-[bot]) Fixed in 0d24902
4 MUST-FIX rejectProp drops rejections that arrive before getProp (@Codex-[bot]) Fixed in 0d24902
5 OPTIONAL End/error handlers registered after pipe() — defensive reorder (@Codex-[bot]) Fixed in 0d24902
6 CI-FIX node_rendering_pool_spec failing — test not updated for new push_props: param Fixed in 90f521a

Declined (with rationale)

# Category Item Reason
7 OPTIONAL Wire-format constants duplicated across packages (@greptile-apps[bot]) Intentional — node renderer is standalone; shared package overhead not warranted yet
8 OPTIONAL parse_and_route_chunk implicit block (@greptile-apps[bot]) Idiomatic Ruby — consistent with surrounding code

Skipped

# Category Item Reason
9 SKIPPED Size-limit report Status post, not actionable feedback
10 SKIPPED CodeRabbit summary Bot acknowledgment, not actionable

All review threads resolved. Monitoring CI.

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR implements bidirectional (pull-mode) streaming for async props, complementing the existing push model. React components can now call getProp() for props not in push_props, causing the Node renderer to emit a propRequest control message over the response stream; Rails parses it, routes it to a PullRequestQueue, and lets the user's block resolve or reject the prop on demand.

  • Node renderer (handleIncrementalRenderRequest.ts): inserts a PassThrough stream to multiplex HTML chunks and control messages, injects a PROP_REQUEST_EMITTER into sharedExecutionContext, and emits renderComplete when React's stream ends.
  • Rails (stream_request.rb, async_props_emitter.rb): parses length-prefixed control messages via LengthPrefixedParser, routes propRequestPullRequestQueue and renderCompleterender_complete!; adds reject() for server-driven prop rejection.
  • Test coverage: five new dummy-app pages, three new React components, a new PropErrorBoundary, and E2E specs for lazy and mixed-mode scenarios via Redis fixtures.

Confidence Score: 3/5

The core streaming path is well-designed and the double render_complete! call is safely guarded, but the write-failure path in AsyncPropsEmitter permanently poisons the pushed_props set before the write completes, leaving React unable to recover a failed prop through pull mode.

The @pushed_props.add(prop_name) mutation happens unconditionally on entry to call, before the stream write that can fail. In pull mode this turns a transient write error into a silent permanent loss: pull requests for the prop are filtered out, no explicit rejection reaches React, and only a generic endStream rejection surfaces much later. The rest of the architecture — the PassThrough multiplexing, PullRequestQueue close-guarding, and the buffered-then-flush emitter setup — is sound.

async_props_emitter.rb needs the @pushed_props.add moved to after a successful write (or rolled back in the rescue block). handleIncrementalRenderRequest.ts and streamingUtils.ts have duplicated wire-format functions that will silently diverge if the protocol changes.

Important Files Changed

Filename Overview
react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb Adds PullRequestQueue and pull-mode support to AsyncPropsEmitter; has a bug where @pushed_props is mutated before the stream write succeeds, silently blocking pull recovery of a failed prop.
packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts Injects a PassThrough stream to multiplex HTML + propRequest/renderComplete control messages; logic is sound but wire-format helpers and shared-context key constants are duplicated from the pro package.
packages/react-on-rails-pro/src/AsyncPropsManager.ts Adds pull-request emission, buffering, flushing, and rejectProp; logic is correct and the buffered-then-flush pattern handles the race between VM rendering and emitter setup.
react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb Routes control messages (propRequest/renderComplete) from the response stream to the emitter; dual render_complete! call is safe due to PullRequestQueue#close guard; parse_and_route_chunk has implicit block dependency.
react_on_rails_pro/lib/react_on_rails_pro/request.rb Threads pull_enabled and push_props through to the NDJSON initial request and returns [response, emitter] tuple for pull mode; clean and correct.
react_on_rails/lib/react_on_rails/length_prefixed_parser.rb Correctly short-circuits the HTML reconstruction path for control messages (messageType present), yields metadata to consumer, and resets state machine; no issues.
packages/react-on-rails-pro/src/streamingUtils.ts Exports formatPropRequestChunk and formatRenderCompleteChunk, but these are duplicated verbatim in handleIncrementalRenderRequest.ts in the node-renderer package.
react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb Adds five test actions and read_lazy_props_from_redis helper; the helper's convention-based key stripping (message_key[1..]) is undocumented enough to be a future footgun.
react_on_rails_pro/spec/dummy/e2e-tests/lazyProps.spec.ts Solid E2E tests covering lazy and mixed push+pull scenarios; rejection_props_for_testing page exists but has no corresponding E2E spec yet.

Sequence Diagram

sequenceDiagram
    participant Rails
    participant NodeRenderer as Node Renderer
    participant ReactVM as React (VM)

    Rails->>NodeRenderer: "NDJSON line 1: {renderingRequest, pullEnabled, pushProps, onRequestClosedUpdateChunk}"
    NodeRenderer->>ReactVM: Execute rendering request
    ReactVM->>ReactVM: AsyncPropsManager.getProp("lazyProp") → buffer propRequest
    NodeRenderer->>NodeRenderer: Set PROP_REQUEST_EMITTER on sharedExecutionContext
    NodeRenderer->>ReactVM: flushPendingPullRequests()
    ReactVM-->>NodeRenderer: propRequest chunk (via injectableStream)
    NodeRenderer-->>Rails: "propRequest {messageType: propRequest, propName: lazyProp}"
    Rails->>Rails: PullRequestQueue.enqueue("lazyProp")
    Rails->>Rails: User block: emit.call("lazyProp", value)
    Rails->>NodeRenderer: "NDJSON: {updateChunk: asyncPropsManager.setProp(...)}"
    NodeRenderer->>ReactVM: Execute setProp → promise resolves
    ReactVM-->>NodeRenderer: HTML chunk (rendered with prop)
    NodeRenderer-->>Rails: HTML chunk (via injectableStream)
    Rails-->>Rails: yield HTML chunk to ActionController::Live
    ReactVM-->>NodeRenderer: React stream ends
    NodeRenderer-->>Rails: renderComplete chunk
    Rails->>Rails: render_complete!() → PullRequestQueue.close()
    Rails->>Rails: while dequeue → nil → block exits
    Rails->>NodeRenderer: Close output stream (END_STREAM)
    NodeRenderer->>ReactVM: Execute onRequestClosedUpdateChunk → endStream()
Loading

Comments Outside Diff (1)

  1. react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb, line 732-736 (link)

    P2 Unconditional first-character strip may silently corrupt prop names

    Both the !-reject and the regular-prop paths strip the first character with message_key[1..]. If a test or future code sends a Redis message whose key does not have a prefix (e.g. a bare prop name like "users"), the first letter is silently dropped — "users" becomes "sers" — with no error. Adding an explicit assertion for unknown prefixes would make the contract visible and catch mistakes early.

Reviews (1): Last reviewed commit: "Fix push-only async props returning [res..." | Re-trigger Greptile

Comment thread react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb Outdated

@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: 7f8ca32f1e

ℹ️ 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".

Comment thread packages/react-on-rails-pro/src/AsyncPropsManager.ts Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@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: 0d24902b9b

ℹ️ 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".

Comment thread packages/react-on-rails-pro/src/AsyncPropsManager.ts

@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: 8bac1e61d1

ℹ️ 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".

Comment thread react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts`:
- Around line 195-202: The propRequest emitter callback writes to
injectableStream without checking if the stream has already ended or been
destroyed. Add a guard condition before the injectableStream.write() call to
check injectableStream.writableEnded or injectableStream.destroyed, and only
proceed with writing the formatted prop request chunk if the stream is still
writable. This prevents late prop requests from attempting to write to a closed
stream and potentially causing async stream errors that bypass the current
try-catch block.

In `@react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb`:
- Around line 53-58: The AsyncPropsEmitter class uses Set on line 56 and
Async::Queue on lines 157, 167, and 174 without explicitly requiring these
dependencies at the top of the file. Add explicit require statements at the
beginning of the file to require the Set standard library and the Async gem (or
its Queue component), ensuring that AsyncPropsEmitter is load-order independent
and all its dependencies are clearly declared locally.

In `@react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb`:
- Around line 441-474: The read_lazy_props_from_redis method creates a Redis
client instance on line 442 but never closes it, which causes resource leaks
from accumulated open sockets. Wrap the Redis operations in an ensure block to
guarantee the connection is closed after all work is complete, regardless of
whether the method exits normally or raises an exception. Close the redis
connection in the ensure block to prevent file descriptor accumulation.

In `@react_on_rails/lib/react_on_rails/length_prefixed_parser.rb`:
- Around line 130-139: The control message handling branch (identified by the
messageType check) currently silently discards any payload without validating
that it is actually zero-length. Add a validation check before yielding the
result to ensure the payload has zero length for control messages like
propRequest and renderComplete. If the payload is non-zero when a control
message is detected, raise an error to reject the malformed message and fail
loudly rather than silently proceeding with incomplete data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: af08a8af-ef3b-4fc9-be5c-409144d7e355

📥 Commits

Reviewing files that changed from the base of the PR and between db01364 and 8bac1e6.

📒 Files selected for processing (22)
  • packages/react-on-rails-pro-node-renderer/src/worker/handleIncrementalRenderRequest.ts
  • packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
  • packages/react-on-rails-pro/src/AsyncPropsManager.ts
  • react_on_rails/lib/react_on_rails/length_prefixed_parser.rb
  • react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb
  • react_on_rails_pro/lib/react_on_rails_pro/request.rb
  • react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb
  • react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb
  • react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb
  • react_on_rails_pro/spec/dummy/app/views/pages/lazy_props_for_testing.html.erb
  • react_on_rails_pro/spec/dummy/app/views/pages/lazy_props_redis_for_testing.html.erb
  • react_on_rails_pro/spec/dummy/app/views/pages/mixed_props_for_testing.html.erb
  • react_on_rails_pro/spec/dummy/app/views/pages/mixed_props_redis_for_testing.html.erb
  • react_on_rails_pro/spec/dummy/app/views/pages/rejection_props_for_testing.html.erb
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyPropRejectionComponent.tsx
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyPropsComponent.tsx
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/MixedPropsComponent.tsx
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/PropErrorBoundary.client.tsx
  • react_on_rails_pro/spec/dummy/config/routes.rb
  • react_on_rails_pro/spec/dummy/e2e-tests/fixture.ts
  • react_on_rails_pro/spec/dummy/e2e-tests/lazyProps.spec.ts
  • react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb

Comment thread react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb
Comment thread react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb
Comment thread react_on_rails/lib/react_on_rails/length_prefixed_parser.rb Outdated
@justin808

Copy link
Copy Markdown
Member

CI process update after #4036 merged:

Please rebase or otherwise update this PR onto current main before relying on CI results. #4036 changed the hosted-CI commands, labels, local CI defaults, and required PR gate behavior.

Post-merge audit tracker: #4055

Recommended update path:

  1. Rebase/update onto current main (git fetch origin main && git rebase origin/main, then git push --force-with-lease if you own the branch; otherwise use the GitHub update/rebase path appropriate for the branch owner).
  2. Run bin/ci-local before pushing; it now auto-detects the PR base and runs optimized local CI by default. Use bin/ci-local --all only when broad local coverage is warranted.
  3. Make sure the PR body has a self-contained Why section; issue links are helpful context, but the PR description should stand on its own.
  4. Use +ci-status before requesting hosted CI.
  5. Use +ci-run-hosted for optimized hosted GitHub Actions after the final push.
  6. Use +ci-force-full only when intentionally bypassing optimized selection.

Vocabulary changes:

  • full-ci -> ready-for-hosted-ci only when optimized hosted CI should persist.
  • full-ci-no-benchmarks -> hosted-ci-no-benchmarks when suppressing benchmark labels.
  • force-full intent -> force-full-hosted-ci or +ci-force-full.
  • Legacy commands +ci-run-full, +ci-skip-full, /run-skipped-ci, /run-skipped-tests, and /stop-run-skipped-ci are removed.

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Code Review: Add bidirectional streaming for async props (pull mode)

Overview

This PR implements a sophisticated bidirectional streaming mechanism for async props. The architecture is well thought out: React components emit propRequest control messages, Node injects them into the response stream via a PassThrough multiplexer, and Rails routes them to a blocking PullRequestQueue. The test coverage (unit + E2E with Redis fixtures) is good.


Critical Issues

1. Nil propName causes premature loop termination

In stream_request.rb, route_control_message does:

@emitter.pull_requests&.enqueue(parsed["propName"])

parsed["propName"] returns nil when the field is absent (e.g. malformed message, future format change). PullRequestQueue#enqueue does not guard against nil, so nil gets enqueued. The user's while (prop_name = emit.pull_requests.dequeue) loop treats nil as the close signal and exits prematurely, silently dropping any unresolved pull requests.

Fix: add a guard in route_control_message or PullRequestQueue#enqueue:

when "propRequest"
  prop_name = parsed["propName"]
  @emitter.pull_requests&.enqueue(prop_name) if prop_name.is_a?(String) && !prop_name.empty?

2. Potential deadlock when process_response_chunks raises unexpectedly

In consume_with_bundle_reupload:

process_response_chunks(stream_response, &block)
@emitter&.render_complete!   # only reached if no exception

The rescue only catches HTTPError. Any other exception (parser bug, unexpected chunk format) propagates without calling render_complete!, leaving the async_props_block blocked in dequeue forever.

Fix: wrap the safety-net in an ensure clause:

process_response_chunks(stream_response, &block)
break
rescue ReactOnRailsPro::RendererHttpClient::HTTPError => e
  ...
ensure
  @emitter&.render_complete!

Medium Issues

3. render_complete! is always called twice on the happy path

On a successful render:

  1. Node sends renderComplete chunk → route_control_message calls @emitter.render_complete!
  2. After process_response_chunks returns → the safety-net at line 165 calls it again

PullRequestQueue#close is idempotent so this is safe, but the comment calling line 165 a "safety-net" is misleading — it fires unconditionally on every successful response, not just as a fallback.

4. Async::Queue::ClosedError — verify the constant name

PullRequestQueue rescues Async::Queue::ClosedError in two places. In some versions of the async gem this error class may not exist, which would silently become a NameError at runtime. Worth a quick confirmation against the gem version in the lockfile, or a direct reference in the spec setup.

5. Comment/code mismatch in read_lazy_props_from_redis

else
  # ":" prefix means set the prop (same as existing convention)
  emitter.call(message_key[1..], JSON.parse(message_value))
end

The comment says ":" prefix but the code strips the first character unconditionally — any leading character works. If only : is intended, add message_key.start_with?(":") check; otherwise update the comment.

6. parse_and_route_chunk uses implicit block / yield without signature declaration

def parse_and_route_chunk(parser, chunk)   # no &block
  parser.feed(chunk) do |parsed|
    yield parsed
  end
end

Ruby supports implicit blocks but this is inconsistent with process_response_chunks's explicit &block declaration. def parse_and_route_chunk(parser, chunk, &block) makes the contract explicit.


Minor / Style

7. payloadType: 'string' in control chunks is written only to be deleted

formatPropRequestChunk / formatRenderCompleteChunk include payloadType: 'string' in metadata, then LengthPrefixedParser immediately deletes it before yielding. If the field serves no parsing purpose, omitting it from the wire format removes the asymmetry. If it IS required by the parser's header-reading path, a comment explaining why would help.

8. flushPendingPullRequests and emitPendingPullRequests have overlapping names

Both are public and called back-to-back from the renderer. The distinction ("buffered before emitter existed" vs "requested before pull mode was enabled") is subtle. The eslint-disable no-param-reassign inside emitPendingPullRequests is also a smell — a private maybeEmitPullRequest(propName, controller) helper would clean this up.


Positive Observations

  • TOCTOU race in PullRequestQueue#enqueue (checking @closed then enqueuing) is correctly handled by rescuing ClosedError.
  • Registering the 'end' handler before pipe() to avoid missing buffered-end events is a known Node.js gotcha; catching it explicitly is correct.
  • The rejectProp pre-silencing of unhandled rejection via .catch(() => {}) before any consumer exists is correct and well-commented.
  • Test coverage is thorough: FIFO ordering, push-prop filtering, idempotent close, and interleaved chunk parsing all covered.

Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb Outdated
Comment thread react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb
@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Code Review: Bidirectional Streaming for Async Props (Pull Mode)

Overall Assessment: Solid architecture — the layered approach (React → Node → Ruby → React) is well thought out and the buffering/flush mechanism handles the tricky timing window cleanly. A few issues worth addressing before merge.


Critical / Bug-Risk

1. @pushed_props not updated on write failure in AsyncPropsEmitter#call

If @request_stream << raises, @pushed_props.add(prop_name) on the next line is skipped. In pull mode, a subsequent propRequest for the same prop would pass the PullRequestQueue filter and be re-enqueued — the user's block would call emit.call again, potentially hitting the same error repeatedly. Fix: move @pushed_props.add(prop_name) before the write, or also call it inside the rescue.


2. E2E tests do not exercise the actual bidirectional pull path

All three Redis E2E test pages route through read_lazy_props_from_redis, which calls emitter.call / emitter.reject directly without ever calling emit.pull_requests.dequeue. The propRequest messages from the Node renderer do reach route_control_message and enqueue to pull_requests, but nobody dequeues them — the queue is silently closed by render_complete!.

The core bidirectional path (React emits propRequest → Rails dequeues it → Rails fetches and sends value) is covered only by the non-Redis ERB views (mixed_props_for_testing, rejection_props_for_testing), which have no automated tests at all.

Consider adding at least one test where the controller calls emit.pull_requests.dequeue in a loop, proving end-to-end that a propRequest unblocks the server-side dequeue.


Correctness

3. Double render_complete! call in consume_with_bundle_reupload

route_control_message calls @emitter.render_complete! when it parses a renderComplete message. The ensure block then calls it again. PullRequestQueue#close is idempotent so this is safe, but a comment noting the safety-net purpose would help future maintainers avoid removing one branch thinking it is redundant.

4. flushPendingPullRequests vs emitPendingPullRequests — overlapping naming

Both are called in sequence in handleIncrementalRenderRequest.ts. They cover genuinely distinct timing gaps (emitter not yet set vs. pull mode not yet enabled), but the names are nearly identical. The docstrings explain the difference, but a concrete example in the comment ("covers props requested before pullEnabled was set on sharedExecutionContext") would prevent future confusion.


Minor

5. Magic string messageType values not centralized

propRequest and renderComplete are hardcoded across TS and Ruby layers. A shared TS constants module referenced from both the node renderer and AsyncPropsManager would prevent typo-driven failures at the language boundary.

6. Test helper format inconsistency

control_message_payload in length_prefixed_parser_spec.rb uses \t0\n (single zero) while production TS emits \t00000000\n (8-digit padded). Both parse to 0 so tests pass, but the wire format is not mirrored exactly.

7. payloadType: 'string' in control chunks is misleading

Both format functions include payloadType: 'string' even though the payload is always empty and the Ruby parser deletes it before yielding. Omitting it from control messages would make the format self-documenting and remove the delete step.

8. PR description vs. diff inconsistency

The architecture table lists streamingUtils.ts as a changed file for formatPropRequestChunk/formatRenderCompleteChunk, but the diff shows these functions living in handleIncrementalRenderRequest.ts.


Test Coverage Summary

Scenario Unit E2E
propRequest emitted from React ⚠️ (Redis bypasses pull path)
LengthPrefixedParser control message routing
PullRequestQueue FIFO / filtering / close
rejectProp → error boundary ✅ (Redis)
pull_requests.dequeue unblocked by propRequest
Stream error → queue closed

Comment thread react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb
@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Code Review: Bidirectional Streaming for Async Props (Pull Mode)

This is a well-structured PR implementing a significant new capability. The architecture is sound and test coverage is solid.


[Security] Rejection reasons are exposed verbatim to the browser. The reason string passed to emit.reject(prop_name, reason) flows through generate_reject_prop_js -> JavaScript -> AsyncPropsManager.rejectProp -> a browser-visible Error message. If a developer accidentally passes an internal error message, exception detail, or stack trace as the rejection reason, it will appear in the browser console and React error boundary. The framework should document this explicitly, and consider whether a sanitization layer or a separate dev/public reason distinction is appropriate.


[Correctness] endStream() now marks resolved = true after rejection -- this was a pre-existing bug fix that prevents double-rejection on a second endStream() call. Good catch.

[Correctness] reject() not adding to @pushed_props is intentional but asymmetric: call() adds the prop to @pushed_props (filtering future pull requests for that prop), but reject() does not. In practice, pullRequested = true on the React side prevents re-emission from a single render. The spec validates this explicitly, so it is intentional -- but a comment explaining why would help future maintainers.

[Correctness] The two-phase catch-up mechanism (flushPendingPullRequests + emitPendingPullRequests) is correct but subtle. These cover two distinct timing gaps: emitter-not-yet-available vs pull-mode-not-yet-enabled. Both are needed and disjoint (guarded by pullRequested). Consider a single unified catchUpPullRequests() to reduce cognitive overhead.


[Code Duplication] formatPropRequestChunk / formatRenderCompleteChunk ended up in handleIncrementalRenderRequest.ts but the PR description says they should be in streamingUtils.ts. Putting wire-format serialization in the call-site file risks the Ruby-side parser and the Node-side emitter drifting out of sync.


[API Design] The push_props: nil vs push_props: [] sentinel is implicit and unvalidated. nil = push-only mode (no pull); [] = pure-pull mode. A developer who passes push_props: nil explicitly, intending to enable pull, silently gets push-only mode instead. Consider an explicit pull_enabled: true keyword, or raising ArgumentError if pull_requests is accessed when pull_enabled: false.


[Tests] No test covers the TOCTOU path in PullRequestQueue#enqueue. The rescue Async::Queue::ClosedError guards a close-between-check-and-enqueue race, but this path is never exercised.

[Tests] Spec helper wire format differs from production. control_message_payload in the LengthPrefixedParser spec adds payloadType: "string" but the production format functions do not send it. The parser handles both cases, but a test for the exact production format would give higher confidence.


[Minor] sleep 1 before raising in read_lazy_props_from_redis (test-only code) -- an unconditional 1-second sleep before raising a missing-param error is odd. Either add an explanatory comment or remove it.

[Minor] PullRequestQueue#dequeue has no timeout. If user code has a bug that prevents the queue from closing, the fiber blocks indefinitely. Acceptable for v1, but worth noting in docs/comments.

Generated with Claude Code

Comment thread react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb
@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #4048: Bidirectional async props streaming (pull mode)

Overview

This PR adds a pull channel to the existing async-props push model: React components can now call getProp() for props that aren't eagerly pushed, and the Node renderer forwards those propRequest control messages back to Rails, which routes them into a new PullRequestQueue for the user's block to drain. The architecture is sound and the implementation is careful throughout.


Strengths

Security — rejection reason sanitization is correctly implemented.
AsyncPropsEmitter#sanitized_rejection_reason always substitutes SANITIZED_REJECTION_REASON, ensuring SQL errors, file paths, and credentials never reach the browser. The spec in async_props_emitter_spec.rb explicitly asserts that internal strings like PG::ConnectionBad and passwords are redacted. This is the right approach.

Backward compatibility via legacy bridge methods.
The two deprecated aliases (flushPendingPullRequests / emitPendingPullRequests) and the dual-shape catchUpAsyncPropsManagerPullBridge function handle mixed-version deploys without breaking older node renderers.

Protocol constant sync tests are a strong safeguard.
asyncPropsProtocol.test.ts asserts that the TS constants in both packages and the Ruby MAX_PULL_PROP_NAME_LENGTH are always in sync. This is exactly the kind of test that prevents protocol drift.

Safety-net render_complete! in ensure.
The ensure in consume_with_bundle_reupload guarantees the pull queue is always closed, even on parser errors or timeouts. Without this, a user's dequeue loop could hang indefinitely. Well handled.


Issues

Medium — STREAM_CONTROL_MESSAGE_TYPES lives at module scope, inconsistent with how LengthPrefixedParser does it

LengthPrefixedParser uses private_constant :CONTROL_MESSAGE_TYPES inside the class. The new STREAM_CONTROL_MESSAGE_TYPES is defined at the ReactOnRailsPro module level outside StreamRequest, leaking it as a public constant. It should move inside StreamRequest and be marked private_constant for consistency.

# react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb, line 312
STREAM_CONTROL_MESSAGE_TYPES = %w[propRequest renderComplete].freeze
private_constant :STREAM_CONTROL_MESSAGE_TYPES  # <- missing

Medium — normalize_executor_result could false-positive on a Hash-like response

The Hash guard discriminates on :response key presence:

def normalize_executor_result(result)
  return result.values_at(:response, :emitter) if result.is_a?(Hash) && result.key?(:response)
  [result, nil]
end

If a future caller (e.g. a middleware that wraps the response in a Rack-style hash with a :response key) returns such a structure, it would be incorrectly destructured into [response, emitter] with emitter = nil — silently disabling pull mode. Consider adding an explicit version sentinel or using a named data class/struct instead.

Low — void anti-pattern in catchUpPropRequests

rejectOversizedPullPropRequest returns the already-rejected promise so callers of getProp can return it. Inside catchUpPropRequests, the return value is discarded with void:

void AsyncPropsManager.rejectOversizedPullPropRequest(propName, controller);

This is safe (the pre-silenced .catch(() => {}) prevents unhandled rejection), but void here signals to the reader that they're discarding a meaningful value, which is confusing. Consider extracting an internal helper that doesn't return the promise for the catch-up path, or returning void directly from the static method when controller.resolved is the goal.

Low — Redundant string in PropErrorBoundary error display

The rendered message chains through two layers:

  • Ruby: SANITIZED_REJECTION_REASON = "Async prop rejected by server"
  • TS: new Error('Prop "${propName}" rejected by server: ${reason}')
  • TSX: Error loading ${propName}: ${error.message}

End result: "Error loading forbiddenData: Prop 'forbiddenData' rejected by server: Async prop rejected by server" — "rejected by server" appears twice. This is a minor UX issue but would be worth cleaning up for user-facing messaging.

Low — Mixed-props Redis E2E test doesn't exercise the pull channel

mixed_props_redis_for_testing.html.erb calls read_lazy_props_from_redis(emit), which resolves all props (including recommendations and relatedPosts) by calling emitter.call() directly from Redis data, bypassing pull_requests.dequeue. React does emit propRequest messages for the non-push props, but they accumulate in the PullRequestQueue and are never serviced — Rails pushes the props proactively instead.

The test validates the correct UI outcome but doesn't exercise the pull protocol path for recommendations/relatedPosts. The dedicated non-Redis mixed_props_for_testing.html.erb view (which explicitly calls emit.pull_requests.dequeue) is the real mixed-mode test. Consider a comment in the E2E test file noting that this fixture tests push-path delivery even for declared-pull props.


Minor observations

  • Endless method syntax (def http_status = @status) is new in Ruby 3.0 and a style departure from the surrounding code. Worth a quick check against the project's Rubocop config.
  • redis_stream_messages refactor in read_async_props_from_redis (updating last_received_id per message rather than after the last) is a correct bug fix — the previous code used received_messages.last.first which could miss IDs mid-batch on resumption. Good catch.
  • The protocol constant sync test reads the Ruby file with readFileSync and a regex. This is clever but fragile — a comment containing the same pattern would match. Importing a generated JSON constant file would be more robust, but this is acceptable for now.

Testing

Coverage for the core paths is solid: protocol constant parity, pull bridge compatibility, queue FIFO/dedup/close behavior, propRequest routing, oversized names, and the ensure safety net. The E2E Playwright tests for lazy/mixed/rejection scenarios close the integration loop.

Overall this is a well-engineered feature. The issues above are all fixable and none block merging, but the STREAM_CONTROL_MESSAGE_TYPES scope leak and the normalize_executor_result fragility are worth addressing before this ships.

Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb Outdated
Comment thread packages/react-on-rails-pro/src/AsyncPropsManager.ts Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb
Comment thread packages/react-on-rails-pro/src/AsyncPropsManager.ts Outdated
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb
Comment thread react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb
promiseController.resolved = true;
}

rejectProp(propName: string, reason: 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.

rejectProp has no explicit isClosed guard. It relies on getOrCreatePromiseController returning undefined when isClosed is true (line 253), which then triggers the if (!promiseController) return at line 129. The test for "rejectProp after endStream does not change the endStream rejection reason" confirms the behaviour is correct, but the coupling is non-obvious — especially because setProp and endStream both open with an explicit if (this.isClosed) check. A matching guard here would make the intent clear to future readers:

Suggested change
rejectProp(propName: string, reason: string) {
rejectProp(propName: string, reason: string) {
if (this.isClosed) {
return;
}
const promiseController = this.getOrCreatePromiseController(propName);

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

test comment

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Code Review: PR 4048 - Bidirectional async props streaming (pull mode)

Overview: This PR adds a pull channel to the existing push-based async props streaming, allowing React components to lazily request props from Rails on demand. The architecture layers cleanly: React emits propRequest control messages, the Node renderer multiplexes them onto the response stream, Rails parses and routes them to a fiber-safe queue, and the view block dequeues and resolves them.

Positives:

  • Backward-compatible opt-in via push_props: parameter; existing callers unaffected.
  • Security-aware rejection: sanitized_rejection_reason always returns a generic string to the browser, preventing leakage of SQL errors, file paths, or credentials.
  • Safety-net render_complete! in the ensure block of consume_with_bundle_reupload prevents permanent dequeue hangs on parser errors, timeouts, or transport aborts.
  • Cross-layer constant sync test (asyncPropsProtocol.test.ts) catches Ruby/TS divergence on MAX_PULL_PROP_NAME_LENGTH.
  • Legacy pull bridge (flushPendingPullRequests / emitPendingPullRequests) supports rolling deploys where the node renderer is upgraded before the Pro gem.
  • Prop-name length guard applied consistently on both the TS side (before emitting) and the Ruby side (before enqueuing).

DISCUSS - CONTROL_MESSAGE_TYPES duplicated without a sync test: CONTROL_MESSAGE_TYPES = %w[propRequest renderComplete] is defined independently in both ReactOnRails::LengthPrefixedParser and ReactOnRailsPro::StreamRequest. The cross-language test checks TS constants against the Ruby prop-name length constant, but nothing validates that the two Ruby constants stay in sync. A future addition (e.g., a renderError type) must be added in both places, and a typo would silently misroute chunks. Consider asserting StreamRequest::CONTROL_MESSAGE_TYPES == LengthPrefixedParser::CONTROL_MESSAGE_TYPES in a spec, or extracting to a single constant one class re-exports.

FOLLOWUP - sharedExecutionContext newly exposed on ExecutionContext type: vm.ts adds sharedExecutionContext: Map<string, unknown> to the exported ExecutionContext type. This raw mutable map bridges the VM sandbox and the host Node.js process. Any code holding an ExecutionContext can now read or write pull-mode keys (pullEnabled, propRequestEmitter, etc.), potentially spoofing or disrupting the protocol. Narrowing the exposure (e.g., a typed accessor instead of the raw map) would reduce attack surface.

FOLLOWUP - blocking Redis read (block: 30_000) can delay cleanup in E2E tests: In read_lazy_props_from_redis, the loop alternates between emit.pull_requests.dequeue and redis.xread with block: LAZY_PROP_REDIS_BLOCK_MS. If renderComplete arrives while the fiber is inside the Redis call, the queue is closed but the fiber stays blocked for up to 30 seconds before reaching dequeue. Correctness is fine (the ensure in stream_request.rb covers the stream side), but this can slow E2E teardown. Checking emitter.pull_requests.closed? after redis_stream_messages returns, or using a shorter block timeout, would tighten the window.

FOLLOWUP - prop_name length not validated in AsyncPropsEmitter#reject: AsyncPropsEmitter#reject(prop_name, reason) passes prop_name directly to generate_reject_prop_js without checking its length. In normal pull-mode flow, route_control_message pre-validates the name before enqueuing. But reject is a public API that callers invoke directly (as in the test views), so there is no guarantee the name has been pre-validated. A defensive guard matching the one in call would provide defense-in-depth.

NON_BLOCKING_DECISION - @received_first_chunk semantics changed: record_first_chunk is now called only when yielded_content is true, so the slow-first-chunk warning fires on the first HTML chunk, not the first any-chunk. This is correct (control messages should not satisfy the marker), but the variable name @received_first_chunk no longer reflects the semantic. A comment or rename to @received_first_content_chunk would help future readers.

Test Coverage Gaps:

  • No unit test for AsyncPropsEmitter#reject when the stream write raises, specifically asserting that @pushed_props is NOT updated on failure (intentional per the comment, but a test makes the invariant explicit).
  • No test verifying that a prop present in both push_props and a subsequent propRequest message is correctly filtered by PullRequestQueue#enqueue via the shared @pushed_props reference.

Summary: The design is sound and the implementation is thorough. Main actionable items: (1) DISCUSS - Add a test or structural guard to keep the two Ruby CONTROL_MESSAGE_TYPES constants in sync. (2) FOLLOWUP - Restrict sharedExecutionContext exposure on the ExecutionContext type. (3) FOLLOWUP - Add a length guard to AsyncPropsEmitter#reject for defense-in-depth. Great work on the legacy bridge, the safety-net ensure block, and the security-aware rejection sanitization.

def http_status
@status
end
MAX_PULL_PROP_NAME_LENGTH = 256

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.

DISCUSS: CONTROL_MESSAGE_TYPES duplicated across two Ruby classes

This constant is also defined verbatim in ReactOnRails::LengthPrefixedParser (OSS gem). The cross-language test (asyncPropsProtocol.test.ts) keeps the TS constants in sync with Ruby, but nothing ensures the two Ruby definitions stay in sync.

A future renderError message type added to one but not the other would silently misroute chunks — LengthPrefixedParser would pass it through as an HTML chunk while StreamRequest would ignore it.

Suggestion: add an RSpec assertion, or move the constant to LengthPrefixedParser and have StreamRequest reference it:

# In stream_request.rb
CONTROL_MESSAGE_TYPES = ReactOnRails::LengthPrefixedParser::CONTROL_MESSAGE_TYPES

That removes the duplication without restructuring either class.

) => Promise<RenderResult>;
getVMContext: (bundleFilePath: string) => VMContext | undefined;
release: () => void;
sharedExecutionContext: Map<string, unknown>;

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.

FOLLOWUP: raw Map exposed on the public type increases attack surface

sharedExecutionContext is now part of the exported ExecutionContext contract. Any downstream code holding an ExecutionContext (tests, renderers, future integrations) can read or overwrite protocol keys like pullEnabled, propRequestEmitter, and asyncPropsManager without going through the intended pull-mode setup path.

Consider narrowing the exposure: expose a typed accessor method instead of the raw map, or keep the map internal and add purpose-built getters/setters for pull-mode state. The pull-mode setup code in handleIncrementalRenderRequest.ts would then call e.g. executionContext.setPropRequestEmitter(fn) rather than writing directly into the map.

@request_stream << "#{update_chunk.to_json}\n"
@pushed_props.add(prop_name)
rescue StandardError => e
# Continue streaming: one failed async prop write should not abort the

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.

FOLLOWUP: reject doesn't validate prop_name length

call (a few lines above) doesn't validate prop_name length either, but in the normal pull-mode flow both names arrive from PullRequestQueue which has already been filtered by route_control_message (max 256 chars, non-empty). However, reject is also invoked directly from user-written ERB view code — e.g., emit.reject("forbiddenData", "Access denied") in the rejection test views. Nothing prevents a caller from passing an arbitrarily long name here.

A defensive guard would match the protection applied on the TS side:

def reject(prop_name, reason)
  if prop_name.length > ReactOnRailsPro::StreamRequest::MAX_PULL_PROP_NAME_LENGTH
    Rails.logger.warn { "[ReactOnRailsPro::AsyncProps] Ignoring reject for oversized prop name (#{prop_name.length} chars)" }
    return
  end
  # ... rest of implementation

[result, nil]
end

def record_first_chunk(request_start_time)

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.

NON_BLOCKING_DECISION: @received_first_chunk now means "first content chunk", not "first any chunk"

The change is intentional and correct — control-only frames should not satisfy the slow-first-chunk marker. But the variable name @received_first_chunk and this helper name still suggest "any chunk", which will confuse a future reader who wonders why a stream whose first byte is a propRequest hasn't triggered the callback.

A short comment here (or a rename to @received_first_content_chunk) would make the intent self-documenting:

# Only HTML/content chunks satisfy the first-chunk timing marker.
# Control messages (propRequest, renderComplete) are excluded intentionally.
def record_first_chunk(request_start_time)
  return if @received_first_chunk

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Code Review: Bidirectional Async Props Streaming (Pull Mode)

This is a well-architected feature with clearly thought-out cross-layer coordination. The protocol design, error handling, and test coverage are all solid. Below is a mix of issues and observations.


Overall Assessment

Architecture

  • The length-prefixed wire format extension (metadata-only control frames with zero body length) is clean and backward-compatible.
  • Using pull_result: true as a discriminator sentinel in the executor return tuple is smart — it's immune to shape collisions with real response hashes.
  • Dual-bridge support (catchUpPropRequests + legacy two-method shim) and the pullRequested flag preventing duplicate emission are both handled correctly.
  • The ensure block in consume_with_bundle_reupload calling render_complete! as a safety net (parser errors, stream aborts, timeouts) is a necessary guard and works correctly.

Security

  • sanitized_rejection_reason always returns the generic constant. to_json escaping in generated JS prevents injection. Good.
  • The ensure_test_only_lazy_props_redis_reader! guard is in the right place.

Test coverage

  • Protocol constant sync tests, drain propagation test, legacy bridge test, over-cap buffer test, and the timeout/empty-read bounds test in pages_controller_spec all cover important edge cases.

Issues Found

1. Dead instance variable: @pull_enabled in StreamRequest (low)
@pull_enabled is stored in initialize (line 126) but never read anywhere in the class — routing is already gated on @emitter being non-nil. The variable adds noise and can be removed.

2. Sensitive data in debug log (medium / security)
sanitized_rejection_reason correctly redacts the browser-facing chunk, but it logs the raw reason at debug level. In log-aggregation setups that collect debug output (common in staging), this would expose SQL errors, file paths, or other details operators passed as rejection reasons. The comment acknowledges this tradeoff, but it is worth deciding explicitly whether to use warn with a sanitized excerpt instead.

3. Drain handler has no timeout/error guard (low / reliability)
In handleIncrementalRenderRequest.ts, when injectableStream.writableNeedDrain is true on the 'end' event, writeRenderCompleteAndEnd is deferred to the next 'drain' event. If the consumer is never drained (e.g., the Rails side stalls permanently), renderComplete is never written and pull_requests.dequeue on the Ruby side would block forever until the safety-net ensure fires. This is probably acceptable in practice since the HTTP/2 stream will eventually be torn down, but the surface is worth documenting.

4. PropErrorBoundary shows full error message for non-rejection errors (low)
String.prototype.replace only strips the prefix when it matches exactly. Any error that bubbles up through the same <Suspense> tree (e.g., a React invariant or a thrown string from an async component) will render its full message. This is a dummy-app component so the impact is limited, but if it is ever promoted to a shared utility the fallback should strip or redact unknown error messages.

5. Incidental bug fix not mentioned in the PR description (FYI)
The PR quietly fixes read_async_props_from_redis (which previously would crash on nil.last.first if xread returned nil or an empty stream). The last_received_id tracking was also changed from received_messages.last.first (before the loop) to per-message tracking inside the loop. These are both correct improvements but should be called out so reviewers understand the scope.

6. emitPropRequest returns true for buffered (deferred) emissions (low / clarity)
The return value means "will eventually be emitted" (true) vs. "dropped over-cap" (false). This is correct and documented in a comment, but the return type boolean is semantically overloaded — it conflates immediate emission with deferred buffering. A union like 'emitted' | 'buffered' | 'dropped' would make catchUpPropRequests's skipping logic more explicit.


Questions

Thread safety for PullRequestQueue in multi-fiber contexts: The TOCTOU window between the @closed guard and @queue.enqueue is rescued correctly. Is there a scenario where render_complete! fires from the Ruby streaming fiber while the user's dequeue loop is blocked in another fiber? The Async::Queue::ClosedError rescue in dequeue handles this, but an explicit concurrent close + dequeue test would be valuable.

No back-pressure for propRequest floods: A component that calls getProp for many props at once is bounded by MAX_BUFFERED_REQUESTS = 500 on the TS side and MAX_PULL_PROP_NAME_LENGTH = 256 on both sides, but there is no rate limit on the Ruby side for processing incoming propRequest control messages. Assuming this is intentional (trusted component code)?


Overall this is production-quality work. The most actionable items are 2 (sensitive data in debug log) and 3 (drain timeout documentation). Items 1, 4–6 are low priority.

def initialize(first_chunk_warn_callback: nil, pull_enabled: false, &request_block)
@request_executor = request_block
@first_chunk_warn_callback = first_chunk_warn_callback
@pull_enabled = pull_enabled

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.

@pull_enabled is set here but never read anywhere else in this class. Control message routing is already gated entirely on @emitter being non-nil (see route_control_message), so this variable is dead code. It can be removed along with the pull_enabled: keyword in initialize and create (or kept as a no-op documentation aid if that's preferred).

Suggested change
@pull_enabled = pull_enabled
@emitter = nil

# operators; keep it below info level because staging log aggregators may
# persist those details.
def sanitized_rejection_reason(reason)
Rails.logger.debug { "[ReactOnRailsPro::AsyncProps] Prop rejected (internal reason): #{reason}" }

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 comment above correctly flags that staging log aggregators may persist debug-level output, but then logs the full raw reason anyway. If reason can contain SQL errors, credentials, or file paths (all common in Rails exceptions), these land in any log sink that enables debug logging.

Consider one of:

  • Truncate/sanitize before logging: reason.to_s.slice(0, 200)
  • Log at debug only when Rails.application.config.log_level <= Logger::DEBUG to make the risk explicit in config
  • Or simply document the expected type of reason so callers know not to pass raw exception messages

The current approach is fine if callers always pass human-written strings (e.g., "Unknown prop: #{prop_name}"), but it creates a footgun for callers who pass e.message directly.

// even if the source stream has already buffered all its data.
sourceStream.on('end', () => {
if (injectableStream.writableNeedDrain) {
injectableStream.once('drain', writeRenderCompleteAndEnd);

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 injectableStream is draining when sourceStream ends, writeRenderCompleteAndEnd is deferred to the 'drain' event. This is correct in the happy path, but if the downstream consumer (Rails/Fastify) stalls indefinitely without draining, the 'drain' event never fires and renderComplete is never written. The Ruby side would then sit in pull_requests.dequeue forever (until the safety-net ensure in consume_with_bundle_reupload fires, which requires the HTTP connection to close).

In practice the HTTP/2 connection teardown is the backstop, but it may be worth adding a comment here to explain the expected cleanup path, or an 'error' / 'close' guard on injectableStream to cancel the pending drain listener:

injectableStream.once('drain', writeRenderCompleteAndEnd);
// If the stream is destroyed before draining, cancel the deferred write.
injectableStream.once('close', () => injectableStream.removeListener('drain', writeRenderCompleteAndEnd));


render() {
if (this.state.error) {
const message = this.state.error.message.replace(rejectedByServerPrefix(this.props.propName), '');

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.

String.prototype.replace (not replaceAll) only strips the first match, but the real concern here is the mismatch path: if an error reaches this boundary that was not thrown by rejectProp (e.g., a React rendering invariant, a thrown string, or a network error from a different fetch), the prefix won't match and the full raw error message is rendered in the DOM. That could expose internal details to the user in production.

This is fine for a dummy/test component, but if PropErrorBoundary is ever promoted to a shared utility, consider guarding on the expected prefix:

const prefix = rejectedByServerPrefix(this.props.propName);
const message = this.state.error.message.startsWith(prefix)
  ? this.state.error.message.slice(prefix.length)
  : 'An unexpected error occurred.';

`${this.overCapBufferedPropRequestCount} over-cap propRequest(s) have stayed unbuffered until catch-up. ` +
'The prop request emitter may not have been installed.',
);
return false;

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 return value semantics here are subtle: true means "tracked and will be emitted eventually" (either immediately or via catchUpPropRequests), while false means "over-cap — not buffered, but still eligible for catch-up because pullRequested stays false".

The over-cap path works correctly because catchUpPropRequests iterates all controllers and picks up any where !controller.pullRequested. But the asymmetry (buffered returns true, over-cap returns false, yet both get caught up) may confuse future readers. A brief comment at the call sites in getProp and catchUpPropRequests noting that pullRequested = false after a dropped buffer means "catch-up eligible" would help.

stream_id = "stream:#{request_id}"
until ended
received_messages = redis.xread(stream_id, last_received_id, block: 0)[stream_id]
received_messages = redis_stream_messages(redis, stream_id, last_received_id)

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 is a silent fix to the existing read_async_props_from_redis method: the original code called received_messages.last.first before the loop, which would raise NoMethodError when xread returned nil (stream key not yet created) or an empty array. The redis_stream_messages helper now returns [] in those cases, preventing the crash.

Worth mentioning this as a bug fix in the PR description so it's visible in the changelog rather than being buried in a feature PR.

@justin808

Copy link
Copy Markdown
Member

Confidence note:

  • Validated: focused Pro/RSC Jest coverage, Pro dummy controller spec, Pro RuboCop, type-check, Prettier/ESLint/diff checks, pre-commit and pre-push hooks; hosted checks are green/skipped as selected, including Claude review and required-pr-gate.
  • Evidence: current head d4b7321; GitHub merge state CLEAN; unresolved review threads 0.
  • UNKNOWN: none.
  • Residual risk: Pro async-props streaming touches RSC pull-mode behavior, but review blockers were addressed and focused/local plus hosted validation passed.

@justin808 justin808 merged commit c32a19d into main Jun 20, 2026
58 checks passed
@justin808 justin808 deleted the 4046-bidirectional-streaming-async-props branch June 20, 2026 23:10
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.

2 participants