From 75d26e6c536ca7c68cc0c46dea0695dd5f1bfc8c Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 20 Nov 2025 19:54:21 +0200 Subject: [PATCH 1/9] Update HTTPX plugin to use stream_bidi for improved streaming capabilities --- react_on_rails_pro/lib/react_on_rails_pro/request.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react_on_rails_pro/lib/react_on_rails_pro/request.rb b/react_on_rails_pro/lib/react_on_rails_pro/request.rb index 4247902d06..e8e45d72b7 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/request.rb @@ -266,7 +266,7 @@ def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize nil end ) - .plugin(:stream) + .plugin(:stream_bidi) # See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options .with( origin: url, From db48f68b424a9c18bddd4a65df95f5620da73bfa Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 20 Nov 2025 21:03:49 +0200 Subject: [PATCH 2/9] Add AsyncPropsEmitter for incremental rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement AsyncPropsEmitter class to support sending async props incrementally during streaming render. This is the first component for Ruby-side incremental rendering support. Features: - call(prop_name, value) API for emitting async props - Generates NDJSON update chunks with bundleTimestamp and updateChunk - Creates JavaScript code to call asyncPropsManager.setProp() - Error handling: logs errors but continues (doesn't abort render) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../react_on_rails_pro/async_props_emitter.rb | 42 ++++++++++++++++++ .../async_props_emitter_spec.rb | 43 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb create mode 100644 react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb new file mode 100644 index 0000000000..c6daa107f2 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # Emitter class for sending async props incrementally during streaming render + # Used by stream_react_component_with_async_props helper + class AsyncPropsEmitter + def initialize(bundle_timestamp, request_stream) + @bundle_timestamp = bundle_timestamp + @request_stream = request_stream + end + + # Public API: emit.call('propName', propValue) + # Sends an update chunk to the node renderer to resolve an async prop + def call(prop_name, prop_value) + update_chunk = generate_update_chunk(prop_name, prop_value) + @request_stream.write("#{update_chunk.to_json}\n") + rescue StandardError => e + Rails.logger.error do + "[ReactOnRailsPro] Failed to send async prop '#{prop_name}': #{e.message}" + end + # Continue - don't abort entire render because one prop failed + end + + private + + def generate_update_chunk(prop_name, value) + { + bundleTimestamp: @bundle_timestamp, + updateChunk: generate_set_prop_js(prop_name, value) + } + end + + def generate_set_prop_js(prop_name, value) + <<~JS.strip + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.setProp(#{prop_name.to_json}, #{value.to_json}); + })() + JS + end + end +end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb new file mode 100644 index 0000000000..5a6953edc9 --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/async_props_emitter_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "react_on_rails_pro/async_props_emitter" + +RSpec.describe ReactOnRailsPro::AsyncPropsEmitter do + let(:bundle_timestamp) { "bundle-12345" } + # rubocop:disable RSpec/VerifiedDoubleReference + let(:request_stream) { instance_double("RequestStream") } + # rubocop:enable RSpec/VerifiedDoubleReference + let(:emitter) { described_class.new(bundle_timestamp, request_stream) } + + describe "#call" do + it "writes NDJSON update chunk with correct structure" do + allow(request_stream).to receive(:write) + + emitter.call("books", ["Book 1", "Book 2"]) + + expect(request_stream).to have_received(:write) do |output| + expect(output).to end_with("\n") + parsed = JSON.parse(output.chomp) + expect(parsed["bundleTimestamp"]).to eq(bundle_timestamp) + expect(parsed["updateChunk"]).to include('sharedExecutionContext.get("asyncPropsManager")') + expect(parsed["updateChunk"]).to include('asyncPropsManager.setProp("books", ["Book 1","Book 2"])') + end + end + + it "logs error and continues without raising when write fails" do + mock_logger = instance_double(Logger) + allow(Rails).to receive(:logger).and_return(mock_logger) + allow(request_stream).to receive(:write).and_raise(StandardError.new("Connection lost")) + allow(mock_logger).to receive(:error) + + expect { emitter.call("books", []) }.not_to raise_error + + expect(mock_logger).to have_received(:error) do |&block| + message = block.call + expect(message).to include("Failed to send async prop 'books'") + expect(message).to include("Connection lost") + end + end + end +end From 9a1209efeecd8e13ea0d76dc27a1c8e08772a561 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 20 Nov 2025 21:12:37 +0200 Subject: [PATCH 3/9] Add prepare_incremental_render_path for incremental rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract common path generation logic into build_render_path and add support for incremental rendering endpoint paths. Changes: - Add prepare_incremental_render_path method for generating /bundles/{hash}/incremental-render/{digest} paths - Extract shared logic to private build_render_path method - Supports both server and RSC bundle selection - Add minimal tests covering path format and bundle selection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../node_rendering_pool.rb | 19 ++++++-- .../node_rendering_pool_spec.rb | 47 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb diff --git a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb index c2f7a99499..a478917d07 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb @@ -96,16 +96,27 @@ def rsc_bundle_hash end def prepare_render_path(js_code, render_options) + # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119 + # From the request path + # path = "/bundles/#{@bundle_hash}/render" + build_render_path(js_code, render_options, "render") + end + + def prepare_incremental_render_path(js_code, render_options) + build_render_path(js_code, render_options, "incremental-render") + end + + private + + def build_render_path(js_code, render_options, endpoint) ReactOnRailsPro::ServerRenderingPool::ProRendering .set_request_digest_on_render_options(js_code, render_options) rsc_support_enabled = ReactOnRailsPro.configuration.enable_rsc_support is_rendering_rsc_payload = rsc_support_enabled && render_options.rsc_payload_streaming? bundle_hash = is_rendering_rsc_payload ? rsc_bundle_hash : server_bundle_hash - # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119 - # From the request path - # path = "/bundles/#{@bundle_hash}/render" - "/bundles/#{bundle_hash}/render/#{render_options.request_digest}" + + "/bundles/#{bundle_hash}/#{endpoint}/#{render_options.request_digest}" end def fallback_exec_js(js_code, render_options, error) diff --git a/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb new file mode 100644 index 0000000000..b1bdce81e0 --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module ReactOnRailsPro + module ServerRenderingPool + RSpec.describe NodeRenderingPool do + let(:js_code) { "console.log('test');" } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + request_digest: "abc123", + rsc_payload_streaming?: false + ) + end + + before do + allow(ReactOnRailsPro::ServerRenderingPool::ProRendering) + .to receive(:set_request_digest_on_render_options) + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(false) + allow(described_class).to receive(:server_bundle_hash).and_return("server123") + allow(described_class).to receive(:rsc_bundle_hash).and_return("rsc456") + end + + describe ".prepare_incremental_render_path" do + it "returns path with incremental-render endpoint" do + path = described_class.prepare_incremental_render_path(js_code, render_options) + + expect(path).to eq("/bundles/server123/incremental-render/abc123") + end + + context "when RSC support is enabled and rendering RSC payload" do + before do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(true) + allow(render_options).to receive(:rsc_payload_streaming?).and_return(true) + end + + it "uses RSC bundle hash instead of server bundle hash" do + path = described_class.prepare_incremental_render_path(js_code, render_options) + + expect(path).to eq("/bundles/rsc456/incremental-render/abc123") + end + end + end + end + end +end From 6f94207948bb59570ebed1368753ab1f737c7a18 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sat, 22 Nov 2025 09:32:49 +0200 Subject: [PATCH 4/9] Add incremental rendering support with bidirectional streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3: Implement render_code_with_incremental_updates and supporting infrastructure This commit adds support for incremental rendering using HTTP2 bidirectional streaming, enabling progressive resolution of async props during server-side rendering. ## Key Changes: **AsyncPropsEmitter** (lib/react_on_rails_pro/async_props_emitter.rb): - Created emitter class for sending async prop updates to Node renderer - Implements `call(prop_name, prop_value)` for emitting updates - Owns generation of `end_stream_chunk` for onRequestClosedUpdateChunk - Encapsulates all update chunk and end stream JavaScript generation **StreamRequest** (lib/react_on_rails_pro/stream_request.rb): - Added Async::Barrier support for non-blocking concurrent task management - Wraps each_chunk execution in Sync block with barrier - Passes barrier to request_executor for spawning async tasks - Ensures proper cleanup and synchronization of async operations **Request** (lib/react_on_rails_pro/request.rb): - Refactored connection architecture with two separate connections: - standard_connection: Uses :stream plugin for regular streaming - incremental_connection: Uses :stream_bidi for bidirectional streaming - Implemented render_code_with_incremental_updates: - Creates bidirectional streaming request with NDJSON format - Spawns async props block in background using barrier.async - Properly closes request stream after async block completes - Selects bundle timestamp based on RSC vs standard rendering - Added build_initial_incremental_request helper using emitter **Tests**: - Added comprehensive tests for render_code_with_incremental_updates - Tests verify NDJSON request format, barrier.async spawning, and emitter usage - Tests for rsc_bundle_hash selection when is_rsc_payload is true - Uses unverified doubles for HTTPX streaming interfaces with justification - Updated stream_request_spec to test barrier parameter passing ## Technical Details: - Uses Async gem with Async::Barrier for concurrent async prop resolution - HTTP2 bidirectional streaming via HTTPX :stream_bidi plugin - NDJSON protocol for streaming communication - Proper separation of concerns: AsyncPropsEmitter owns chunk generation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/helpers/react_on_rails_pro_helper.rb | 5 + .../react_on_rails_pro/async_props_emitter.rb | 18 ++++ .../lib/react_on_rails_pro/request.rb | 74 +++++++++++-- .../lib/react_on_rails_pro/stream_request.rb | 41 ++++--- react_on_rails_pro/spec/dummy/Procfile.dev | 2 +- .../spec/react_on_rails_pro/request_spec.rb | 101 ++++++++++++++++++ .../react_on_rails_pro/stream_request_spec.rb | 44 ++++++++ 7 files changed, 262 insertions(+), 23 deletions(-) diff --git a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb index 69c20af010..2e20ebf159 100644 --- a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb +++ b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb @@ -137,6 +137,11 @@ def stream_react_component(component_name, options = {}) end end + def stream_react_component_with_async_props(component_name, options = {}, &props_block) + options[:async_props_block] = props_block + stream_react_component(component_name, options) + end + # Renders the React Server Component (RSC) payload for a given component. This helper generates # a special format designed by React for serializing server components and transmitting them # to the client. diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb index c6daa107f2..80f9ee2fd0 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb @@ -21,6 +21,15 @@ def call(prop_name, prop_value) # Continue - don't abort entire render because one prop failed end + # Generates the chunk that should be executed when the request stream closes + # This tells the asyncPropsManager to end the stream + def end_stream_chunk + { + bundleTimestamp: @bundle_timestamp, + updateChunk: generate_end_stream_js + } + end + private def generate_update_chunk(prop_name, value) @@ -38,5 +47,14 @@ def generate_set_prop_js(prop_name, value) })() JS end + + def generate_end_stream_js + <<~JS.strip + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.endStream(); + })() + JS + end end end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/request.rb b/react_on_rails_pro/lib/react_on_rails_pro/request.rb index e8e45d72b7..f11bd4e889 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/request.rb @@ -3,13 +3,16 @@ require "uri" require "httpx" require_relative "stream_request" +require_relative "async_props_emitter" module ReactOnRailsPro class Request # rubocop:disable Metrics/ClassLength class << self def reset_connection - @connection&.close - @connection = create_connection + @standard_connection&.close + @incremental_connection&.close + @standard_connection = nil + @incremental_connection = nil end def render_code(path, js_code, send_bundle) @@ -27,7 +30,7 @@ def render_code_as_stream(path, js_code, is_rsc_payload:) "rendering any RSC payload." end - ReactOnRailsPro::StreamRequest.create do |send_bundle| + ReactOnRailsPro::StreamRequest.create do |send_bundle, _barrier| if send_bundle Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" } upload_assets @@ -38,6 +41,45 @@ def render_code_as_stream(path, js_code, is_rsc_payload:) end end + def render_code_with_incremental_updates(path, js_code, async_props_block:, is_rsc_payload:) + Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" } + + # Determine bundle timestamp based on RSC support + pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool + bundle_timestamp = is_rsc_payload ? pool.rsc_bundle_hash : pool.server_bundle_hash + + ReactOnRailsPro::StreamRequest.create do |send_bundle, barrier| + if send_bundle + Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" } + upload_assets + end + + # Build bidirectional streaming request + request = incremental_connection.build_request( + "POST", + path, + headers: { "content-type" => "application/x-ndjson" }, + body: [] + ) + + # Create emitter and use it to generate initial request data + emitter = ReactOnRailsPro::AsyncPropsEmitter.new(bundle_timestamp, request) + initial_data = build_initial_incremental_request(js_code, emitter) + request.write("#{initial_data.to_json}\n") + + response = incremental_connection.request(request, stream: true) + + # Execute async props block in background using barrier + barrier.async do + async_props_block.call(emitter) + ensure + request.close + end + + response + end + end + def upload_assets Rails.logger.info { "[ReactOnRailsPro] Uploading assets" } @@ -87,8 +129,14 @@ def asset_exists_on_vm_renderer?(filename) private + # rubocop:disable Naming/MemoizedInstanceVariableName def connection - @connection ||= create_connection + @standard_connection ||= create_standard_connection + end + # rubocop:enable Naming/MemoizedInstanceVariableName + + def incremental_connection + @incremental_connection ||= create_incremental_connection end def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity @@ -222,7 +270,22 @@ def common_form_data ReactOnRailsPro::Utils.common_form_data end - def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def build_initial_incremental_request(js_code, emitter) + common_form_data.merge( + renderingRequest: js_code, + onRequestClosedUpdateChunk: emitter.end_stream_chunk + ) + end + + def create_standard_connection + build_connection_config.plugin(:stream) + end + + def create_incremental_connection + build_connection_config.plugin(:stream_bidi) + end + + def build_connection_config # rubocop:disable Metrics/MethodLength, Metrics/AbcSize url = ReactOnRailsPro.configuration.renderer_url Rails.logger.info do "[ReactOnRailsPro] Setting up Node Renderer connection to #{url}" @@ -266,7 +329,6 @@ def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize nil end ) - .plugin(:stream_bidi) # See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options .with( origin: url, diff --git a/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb b/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb index 4090958a0f..6a72eaf235 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "async" +require "async/barrier" + module ReactOnRailsPro class StreamDecorator def initialize(component) @@ -92,22 +95,28 @@ def initialize(&request_block) def each_chunk(&block) return enum_for(:each_chunk) unless block - send_bundle = false - error_body = +"" - loop do - stream_response = @request_executor.call(send_bundle) - - # Chunks can be merged during streaming, so we separate them by newlines - # Also, we check the status code inside the loop block because calling `status` outside the loop block - # is blocking, it will wait for the response to be fully received - # Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details - process_response_chunks(stream_response, error_body, &block) - break - rescue HTTPX::HTTPError => e - send_bundle = handle_http_error(e, error_body, send_bundle) - rescue HTTPX::ReadTimeoutError => e - raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \ - "Original error:\n#{e}\n#{e.backtrace}" + Sync do + barrier = Async::Barrier.new + + send_bundle = false + error_body = +"" + loop do + stream_response = @request_executor.call(send_bundle, barrier) + + # Chunks can be merged during streaming, so we separate them by newlines + # Also, we check the status code inside the loop block because calling `status` outside the loop block + # is blocking, it will wait for the response to be fully received + # Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details + process_response_chunks(stream_response, error_body, &block) + break + rescue HTTPX::HTTPError => e + send_bundle = handle_http_error(e, error_body, send_bundle) + rescue HTTPX::ReadTimeoutError => e + raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \ + "Original error:\n#{e}\n#{e.backtrace}" + end + + barrier.wait end end diff --git a/react_on_rails_pro/spec/dummy/Procfile.dev b/react_on_rails_pro/spec/dummy/Procfile.dev index 4b5b336ea5..74c5432fd8 100644 --- a/react_on_rails_pro/spec/dummy/Procfile.dev +++ b/react_on_rails_pro/spec/dummy/Procfile.dev @@ -1,6 +1,6 @@ # Procfile for development with hot reloading of JavaScript and CSS -rails: rails s -p 3000 +# rails: rails s -p 3000 # Run the hot reload server for client development webpack-dev-server: HMR=true bin/shakapacker-dev-server diff --git a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb index 495fe9f9e3..45abb7e733 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb @@ -2,6 +2,9 @@ require_relative "spec_helper" require "fakefs/safe" +require "httpx" + +HTTPX::Plugins.load_plugin(:stream) describe ReactOnRailsPro::Request do let(:logger_mock) { instance_double(ActiveSupport::Logger).as_null_object } @@ -218,4 +221,102 @@ end end end + + # Unverified doubles are required for HTTPX bidirectional streaming because: + # 1. HTTPX::StreamResponse doesn't define status in its interface (causes verified double failures) + # 2. The :stream_bidi plugin adds methods (#write, #close, #build_request) not in standard interfaces + # 3. Using double(ClassName) documents the class while allowing interface flexibility + # rubocop:disable RSpec/VerifiedDoubles, RSpec/MultipleMemoizedHelpers + describe "render_code_with_incremental_updates" do + let(:js_code) { "console.log('incremental rendering');" } + let(:async_props_block) { proc { |_emitter| } } + let(:mock_request) { double(HTTPX::Request) } + let(:mock_response) { double(HTTPX::StreamResponse, status: 200) } + let(:mock_connection) { double(HTTPX::Session) } + + before do + allow(ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool).to receive_messages( + server_bundle_hash: "server_bundle.js", + rsc_bundle_hash: "rsc_bundle.js" + ) + + allow(mock_connection).to receive_messages(build_request: mock_request, request: mock_response) + allow(mock_request).to receive(:close) + allow(mock_request).to receive(:write) + allow(mock_response).to receive(:is_a?).with(HTTPX::ErrorResponse).and_return(false) + allow(mock_response).to receive(:each).and_yield("chunk\n") + allow(described_class).to receive(:incremental_connection).and_return(mock_connection) + + # Stub AsyncPropsEmitter to return a mock with end_stream_chunk + allow(ReactOnRailsPro::AsyncPropsEmitter).to receive(:new) do |_bundle_timestamp, _request| + double( + ReactOnRailsPro::AsyncPropsEmitter, + end_stream_chunk: { bundleTimestamp: "mocked", updateChunk: "mocked_js" } + ) + end + end + + it "creates NDJSON request with correct initial data" do + stream = described_class.render_code_with_incremental_updates( + "/render-incremental", + js_code, + async_props_block: async_props_block, + is_rsc_payload: false + ) + + stream.each_chunk(&:itself) + + expect(mock_connection).to have_received(:build_request).with( + "POST", + "/render-incremental", + headers: { "content-type" => "application/x-ndjson" }, + body: [] + ) + expect(mock_request).to have_received(:write).at_least(:once) + end + + it "spawns barrier.async task and passes emitter to async_props_block" do + emitter_received = nil + test_async_props_block = proc { |emitter| emitter_received = emitter } + + # Allow real emitter to be created for this test + allow(ReactOnRailsPro::AsyncPropsEmitter).to receive(:new).and_call_original + + stream = described_class.render_code_with_incremental_updates( + "/render-incremental", + js_code, + async_props_block: test_async_props_block, + is_rsc_payload: false + ) + + stream.each_chunk(&:itself) + + expect(emitter_received).to be_a(ReactOnRailsPro::AsyncPropsEmitter) + end + + it "uses rsc_bundle_hash when is_rsc_payload is true" do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(true) + + emitter_captured = nil + allow(ReactOnRailsPro::AsyncPropsEmitter).to receive(:new) do |bundle_timestamp, request_stream| + emitter_captured = { bundle_timestamp: bundle_timestamp, request_stream: request_stream } + double( + ReactOnRailsPro::AsyncPropsEmitter, + end_stream_chunk: { bundleTimestamp: bundle_timestamp, updateChunk: "mocked_js" } + ) + end + + stream = described_class.render_code_with_incremental_updates( + "/render-incremental", + js_code, + async_props_block: async_props_block, + is_rsc_payload: true + ) + + stream.each_chunk(&:itself) + + expect(emitter_captured[:bundle_timestamp]).to eq("rsc_bundle.js") + end + end + # rubocop:enable RSpec/VerifiedDoubles, RSpec/MultipleMemoizedHelpers end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb index c8a12e5b0c..f3d8e84963 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/stream_request_spec.rb @@ -2,6 +2,10 @@ require_relative "spec_helper" require "react_on_rails_pro/stream_request" +require "async/barrier" +require "httpx" + +HTTPX::Plugins.load_plugin(:stream) RSpec.describe ReactOnRailsPro::StreamRequest do describe ".create" do @@ -10,4 +14,44 @@ expect(result).to be_a(ReactOnRailsPro::StreamDecorator) end end + + # Unverified doubles are required for streaming responses because: + # 1. HTTP streaming responses don't have a dedicated class type in HTTPX + # 2. The #each method for streaming is added dynamically at runtime + # 3. The interface varies based on the streaming mode (HTTP/2, chunked, etc.) + # rubocop:disable RSpec/VerifiedDoubles + describe "#each_chunk with barrier" do + it "passes barrier to request_executor block" do + barrier_received = nil + mock_response = double(HTTPX::StreamResponse, status: 200) + allow(mock_response).to receive(:is_a?).with(HTTPX::ErrorResponse).and_return(false) + allow(mock_response).to receive(:each).and_yield("chunk\n") + + stream = described_class.create do |_send_bundle, barrier| + barrier_received = barrier + mock_response + end + + stream.each_chunk(&:itself) + + expect(barrier_received).to be_a(Async::Barrier) + end + + it "calls barrier.wait after yielding chunks" do + barrier = Async::Barrier.new + allow(Async::Barrier).to receive(:new).and_return(barrier) + expect(barrier).to receive(:wait) + + mock_response = double(HTTPX::StreamResponse, status: 200) + allow(mock_response).to receive(:is_a?).with(HTTPX::ErrorResponse).and_return(false) + allow(mock_response).to receive(:each).and_yield("chunk\n") + + stream = described_class.create do |_send_bundle, _barrier| + mock_response + end + + stream.each_chunk(&:itself) + end + end + # rubocop:enable RSpec/VerifiedDoubles end From 82de2beb70fba85ad65d7bd15375fb79b72cc9a3 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sat, 22 Nov 2025 14:05:01 +0200 Subject: [PATCH 5/9] Wire up incremental rendering in NodeRenderingPool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modify eval_streaming_js to detect async_props_block in render_options - Route to incremental rendering path when async_props_block is present - Fall back to standard streaming when async_props_block is absent - Add tests for both execution paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../node_rendering_pool.rb | 27 +++++++--- .../node_rendering_pool_spec.rb | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb index a478917d07..75c15e7d0c 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb @@ -53,12 +53,27 @@ def exec_server_render_js(js_code, render_options) end def eval_streaming_js(js_code, render_options) - path = prepare_render_path(js_code, render_options) - ReactOnRailsPro::Request.render_code_as_stream( - path, - js_code, - is_rsc_payload: ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming? - ) + is_rsc_payload = ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming? + async_props_block = render_options.internal_option(:async_props_block) + + if async_props_block + # Use incremental rendering when async props block is provided + path = prepare_incremental_render_path(js_code, render_options) + ReactOnRailsPro::Request.render_code_with_incremental_updates( + path, + js_code, + async_props_block: async_props_block, + is_rsc_payload: is_rsc_payload + ) + else + # Use standard streaming when no async props block + path = prepare_render_path(js_code, render_options) + ReactOnRailsPro::Request.render_code_as_stream( + path, + js_code, + is_rsc_payload: is_rsc_payload + ) + end end def eval_js(js_code, render_options, send_bundle: false) diff --git a/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb index b1bdce81e0..415d158211 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb @@ -42,6 +42,59 @@ module ServerRenderingPool end end end + + describe ".eval_streaming_js" do + context "when async_props_block is present in render_options" do + let(:async_props_block) { proc { { data: "async_data" } } } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + rsc_payload_streaming?: false, + internal_option: async_props_block + ) + end + + it "calls prepare_incremental_render_path and render_code_with_incremental_updates" do + expected_path = "/bundles/server123/incremental-render/abc123" + allow(described_class).to receive(:prepare_incremental_render_path) + .with(js_code, render_options) + .and_return(expected_path) + allow(ReactOnRailsPro::Request).to receive(:render_code_with_incremental_updates) + + described_class.eval_streaming_js(js_code, render_options) + + expect(described_class).to have_received(:prepare_incremental_render_path) + .with(js_code, render_options) + expect(ReactOnRailsPro::Request).to have_received(:render_code_with_incremental_updates) + .with(expected_path, js_code, async_props_block: async_props_block, is_rsc_payload: false) + end + end + + context "when async_props_block is NOT present" do + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + rsc_payload_streaming?: false, + internal_option: nil + ) + end + + it "calls prepare_render_path and render_code_as_stream" do + expected_path = "/bundles/server123/render/abc123" + allow(described_class).to receive(:prepare_render_path) + .with(js_code, render_options) + .and_return(expected_path) + allow(ReactOnRailsPro::Request).to receive(:render_code_as_stream) + + described_class.eval_streaming_js(js_code, render_options) + + expect(described_class).to have_received(:prepare_render_path) + .with(js_code, render_options) + expect(ReactOnRailsPro::Request).to have_received(:render_code_as_stream) + .with(expected_path, js_code, is_rsc_payload: false) + end + end + end end end end From 19f1ed2fb8406095dacbbb61126456ef5a9e8a0b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sat, 22 Nov 2025 14:13:12 +0200 Subject: [PATCH 6/9] Add AsyncPropsManager JavaScript code generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add async_props_setup_js helper method to generate AsyncPropsManager setup code - Generate code when async_props_block is present in render_options - Initialize AsyncPropsManager and store in sharedExecutionContext - Add tests for both async_props_setup_js and render method 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../server_rendering_js_code.rb | 16 +++ .../server_rendering_js_code_spec.rb | 119 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 react_on_rails_pro/spec/react_on_rails_pro/server_rendering_js_code_spec.rb diff --git a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb index ff2da52ee9..fb0919dd86 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb @@ -46,6 +46,21 @@ def generate_rsc_payload_js_function(render_options) JS end + # Generates JavaScript code for async props setup when incremental rendering is enabled + # @param render_options [Object] Options that control the rendering behavior + # @return [String] JavaScript code that sets up AsyncPropsManager or empty string + def async_props_setup_js(render_options) + return "" unless render_options.internal_option(:async_props_block) + + <<-JS + if (ReactOnRails.isRSCBundle) { + var { props: propsWithAsyncProps, asyncPropManager } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps); + usedProps = propsWithAsyncProps; + sharedExecutionContext.set("asyncPropsManager", asyncPropManager); + } + JS + end + # Main rendering function that generates JavaScript code for server-side rendering # @param props_string [String] JSON string of props to pass to the React component # @param rails_context [String] JSON string of Rails context data @@ -84,6 +99,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend #{ssr_pre_hook_js} #{redux_stores} var usedProps = typeof props === 'undefined' ? #{props_string} : props; + #{async_props_setup_js(render_options)} return ReactOnRails[#{render_function_name}]({ name: componentName, domNodeId: '#{render_options.dom_id}', diff --git a/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_js_code_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_js_code_spec.rb new file mode 100644 index 0000000000..312eec41e1 --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/server_rendering_js_code_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "react_on_rails_pro/server_rendering_js_code" + +RSpec.describe ReactOnRailsPro::ServerRenderingJsCode do + describe ".async_props_setup_js" do + context "when async_props_block is NOT present in render_options" do + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: nil + ) + end + + it "returns empty string" do + result = described_class.async_props_setup_js(render_options) + + expect(result).to eq("") + end + end + + context "when async_props_block is present in render_options" do + let(:async_props_block) { proc { { data: "async_data" } } } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: async_props_block + ) + end + + it "returns JavaScript code that sets up AsyncPropsManager" do + result = described_class.async_props_setup_js(render_options) + + expect(result).to include("ReactOnRails.isRSCBundle") + expect(result).to include("ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps)") + expect(result).to include("propsWithAsyncProps") + expect(result).to include("asyncPropManager") + expect(result).to include('sharedExecutionContext.set("asyncPropsManager", asyncPropManager)') + expect(result).to include("usedProps = propsWithAsyncProps") + end + end + end + + describe ".render" do + let(:props_string) { '{"name":"Test"}' } + let(:rails_context) { '{"serverSide":true}' } + let(:redux_stores) { "" } + let(:react_component_name) { "TestComponent" } + + context "when async_props_block is present" do + let(:async_props_block) { proc { { data: "async_data" } } } + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: async_props_block, + streaming?: false, + dom_id: "TestComponent-0", + trace: false + ) + end + + before do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:throw_js_errors).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:rendering_returns_promises).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:ssr_pre_hook_js).and_return(nil) + end + + it "includes async props setup JavaScript in the generated code" do + result = described_class.render( + props_string, + rails_context, + redux_stores, + react_component_name, + render_options + ) + + expect(result).to include("var usedProps = typeof props === 'undefined' ?") + expect(result).to include("ReactOnRails.isRSCBundle") + expect(result).to include("ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps)") + expect(result).to include('sharedExecutionContext.set("asyncPropsManager", asyncPropManager)') + end + end + + context "when async_props_block is NOT present" do + let(:render_options) do + instance_double( + ReactOnRails::ReactComponent::RenderOptions, + internal_option: nil, + streaming?: false, + dom_id: "TestComponent-0", + trace: false + ) + end + + before do + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:throw_js_errors).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:rendering_returns_promises).and_return(false) + allow(ReactOnRailsPro.configuration).to receive(:ssr_pre_hook_js).and_return(nil) + end + + it "does NOT include async props setup JavaScript in the generated code" do + result = described_class.render( + props_string, + rails_context, + redux_stores, + react_component_name, + render_options + ) + + expect(result).to include("var usedProps = typeof props === 'undefined' ?") + expect(result).not_to include("ReactOnRails.addAsyncPropsCapabilityToComponentProps") + expect(result).not_to include("asyncPropManager") + end + end + end +end From 075e91e373555e5102ee218254fa492a74227544 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 23 Nov 2025 12:54:18 +0200 Subject: [PATCH 7/9] Fix HTTPX stream_bidi plugin bug preventing connection cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add monkey-patch for missing inflight? method in HTTPX::Plugins::StreamBidi::Signal class. This fixes NoMethodError when closing persistent bidirectional streaming connections, which is required for incremental rendering feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- react_on_rails_pro/lib/react_on_rails_pro.rb | 3 ++ .../httpx_stream_bidi_patch.rb | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb diff --git a/react_on_rails_pro/lib/react_on_rails_pro.rb b/react_on_rails_pro/lib/react_on_rails_pro.rb index 5dbe2dafae..244d90c6cc 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro.rb @@ -2,6 +2,9 @@ require "rails" +# Apply HTTPX bug fix for stream_bidi plugin +require "react_on_rails_pro/httpx_stream_bidi_patch" + require "react_on_rails_pro/request" require "react_on_rails_pro/version" require "react_on_rails_pro/constants" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb b/react_on_rails_pro/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb new file mode 100644 index 0000000000..81d0c1aeaf --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Temporary monkey-patch for HTTPX bug with stream_bidi plugin + persistent connections +# +# Issue: When using HTTPX with both `persistent: true` and `.plugin(:stream_bidi)`, +# calling `session.close` raises NoMethodError: undefined method `inflight?` for +# an instance of HTTPX::Plugins::StreamBidi::Signal +# +# Root cause: The StreamBidi::Signal class is registered as a selectable in the +# selector but doesn't implement the `inflight?` method required by Selector#terminate +# (called during session close at lib/httpx/selector.rb:64) +# +# This patch adds the missing `inflight?` method to Signal. The method returns false +# because Signal objects are just pipe-based notification mechanisms to wake up the +# selector loop - they never have "inflight" HTTP requests or pending data buffers. +# +# The `unless method_defined?` guard ensures this patch won't override the method +# when the official fix is released, making it safe to keep in the codebase. +# +# Can be removed once httpx releases an official fix. +# Affected versions: httpx 1.5.1 (and possibly earlier) +# See: https://github.com/HoneyryderChuck/httpx/issues/XXX + +module HTTPX + module Plugins + module StreamBidi + class Signal + unless method_defined?(:inflight?) + def inflight? + false + end + end + end + end + end +end From a4725e1b85fa8507475f0f026aaaa8f274ab695b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 23 Nov 2025 13:48:08 +0200 Subject: [PATCH 8/9] Add integration tests for incremental rendering with bidirectional streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive integration tests to verify incremental rendering functionality: - Test bundle upload to node renderer using fixture bundles - Test simple non-streaming render request using ReactOnRails.dummy - Test incremental rendering with stream values - Test bidirectional streaming to ensure chunks are received concurrently Tests use simple fixture bundles from packages/node-renderer/tests/fixtures/ to avoid complexity and focus on testing the HTTP/streaming protocol between Ruby and the Node renderer. Key testing approach: - Mock populate_form_with_bundle_and_assets to use real fixture bundles - Mock AsyncPropsEmitter to generate proper update chunks - Use Async::Condition for bidirectional streaming verification - Timeout wrapper prevents deadlock if streaming isn't truly bidirectional 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../react_on_rails_pro/async_props_emitter.rb | 2 +- .../lib/react_on_rails_pro/request.rb | 2 +- .../incremental_rendering_integration_spec.rb | 205 ++++++++++++++++++ 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 react_on_rails_pro/spec/dummy/spec/requests/incremental_rendering_integration_spec.rb diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb index 80f9ee2fd0..078bb9a085 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb @@ -13,7 +13,7 @@ def initialize(bundle_timestamp, request_stream) # Sends an update chunk to the node renderer to resolve an async prop def call(prop_name, prop_value) update_chunk = generate_update_chunk(prop_name, prop_value) - @request_stream.write("#{update_chunk.to_json}\n") + @request_stream << "#{update_chunk.to_json}\n" rescue StandardError => e Rails.logger.error do "[ReactOnRailsPro] Failed to send async prop '#{prop_name}': #{e.message}" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/request.rb b/react_on_rails_pro/lib/react_on_rails_pro/request.rb index f11bd4e889..cc704bf48a 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/request.rb @@ -65,9 +65,9 @@ def render_code_with_incremental_updates(path, js_code, async_props_block:, is_r # Create emitter and use it to generate initial request data emitter = ReactOnRailsPro::AsyncPropsEmitter.new(bundle_timestamp, request) initial_data = build_initial_incremental_request(js_code, emitter) - request.write("#{initial_data.to_json}\n") response = incremental_connection.request(request, stream: true) + request << "#{initial_data.to_json}\n" # Execute async props block in background using barrier barrier.async do diff --git a/react_on_rails_pro/spec/dummy/spec/requests/incremental_rendering_integration_spec.rb b/react_on_rails_pro/spec/dummy/spec/requests/incremental_rendering_integration_spec.rb new file mode 100644 index 0000000000..f71152bfb7 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/spec/requests/incremental_rendering_integration_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Integration tests for incremental rendering with bidirectional streaming +# +# IMPORTANT: These tests require a running node-renderer server. +# Before running these tests: +# 1. cd packages/node-renderer +# 2. yarn test:setup # or equivalent command to start the test server +# 3. Keep the server running in a separate terminal +# +# Then run these tests: +# bundle exec rspec spec/requests/incremental_rendering_integration_spec.rb +# +describe "Incremental Rendering Integration", :integration do + let(:server_bundle_hash) { "test_incremental_bundle" } + # Fixture bundle paths (real files on disk) + let(:fixture_bundle_path) do + File.expand_path( + "../../../../packages/node-renderer/tests/fixtures/bundle-incremental.js", + __dir__ + ) + end + let(:fixture_rsc_bundle_path) do + File.expand_path( + "../../../../packages/node-renderer/tests/fixtures/secondary-bundle-incremental.js", + __dir__ + ) + end + let(:rsc_bundle_hash) { "test_incremental_rsc_bundle" } + + before do + allow(ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool).to receive_messages( + server_bundle_hash: server_bundle_hash, + rsc_bundle_hash: rsc_bundle_hash, + renderer_bundle_file_name: "#{server_bundle_hash}.js", + rsc_renderer_bundle_file_name: "#{rsc_bundle_hash}.js" + ) + + # Enable RSC support for these tests + allow(ReactOnRailsPro.configuration).to receive(:enable_rsc_support).and_return(true) + + # Mock populate_form_with_bundle_and_assets to use fixture bundles directly + # rubocop:disable Lint/UnusedBlockArgument + allow(ReactOnRailsPro::Request).to receive(:populate_form_with_bundle_and_assets) do |form, check_bundle:| + # rubocop:enable Lint/UnusedBlockArgument + form["bundle_#{server_bundle_hash}"] = { + body: Pathname.new(fixture_bundle_path), + content_type: "text/javascript", + filename: "#{server_bundle_hash}.js" + } + + form["bundle_#{rsc_bundle_hash}"] = { + body: Pathname.new(fixture_rsc_bundle_path), + content_type: "text/javascript", + filename: "#{rsc_bundle_hash}.js" + } + end + + # Mock AsyncPropsEmitter chunk generation methods to work with fixture bundles + # Only mock the chunk generation, not the actual call/streaming logic + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(ReactOnRailsPro::AsyncPropsEmitter) + .to receive(:generate_update_chunk) do |emitter, _prop_name, value| + bundle_timestamp = emitter.instance_variable_get(:@bundle_timestamp) + { + bundleTimestamp: bundle_timestamp, + # Add newline to the value so the fixture bundle writes it with newline + updateChunk: "ReactOnRails.addStreamValue(#{value.to_json} + '\\n')" + } + end + + allow_any_instance_of(ReactOnRailsPro::AsyncPropsEmitter).to receive(:end_stream_chunk).and_call_original + allow_any_instance_of(ReactOnRailsPro::AsyncPropsEmitter).to receive(:generate_end_stream_js).and_return( + "ReactOnRails.endStream()" + ) + # rubocop:enable RSpec/AnyInstance + + # Reset any existing connections to ensure clean state + ReactOnRailsPro::Request.reset_connection + end + + after do + ReactOnRailsPro::Request.reset_connection + end + + describe "upload_assets" do + it "successfully uploads fixture bundles to the node renderer" do + expect do + ReactOnRailsPro::Request.upload_assets + end.not_to raise_error + end + end + + describe "render_code" do + it "renders simple non-streaming request using ReactOnRails.dummy" do + # Upload bundles first + ReactOnRailsPro::Request.upload_assets + + # Construct the render path: /bundles/:bundleTimestamp/render/:renderRequestDigest + js_code = "ReactOnRails.dummy" + request_digest = Digest::MD5.hexdigest(js_code) + render_path = "/bundles/#{server_bundle_hash}/render/#{request_digest}" + + # Render using the fixture bundle + response = ReactOnRailsPro::Request.render_code(render_path, js_code, false) + + expect(response.status).to eq(200) + expect(response.body.to_s).to include("Dummy Object") + end + end + + describe "render_code_with_incremental_updates" do + it "sends stream values and receives them in the response" do + # Upload bundles first + ReactOnRailsPro::Request.upload_assets + + # Construct the incremental render path + js_code = "ReactOnRails.getStreamValues()" + request_digest = Digest::MD5.hexdigest(js_code) + render_path = "/bundles/#{server_bundle_hash}/incremental-render/#{request_digest}" + + # Perform incremental rendering with stream updates + stream = ReactOnRailsPro::Request.render_code_with_incremental_updates( + render_path, + js_code, + async_props_block: proc { |emitter| + emitter.call("prop1", "value1") + emitter.call("prop2", "value2") + emitter.call("prop3", "value3") + }, + is_rsc_payload: false + ) + + # Collect all chunks from the stream + chunks = [] + stream.each_chunk do |chunk| + chunks << chunk + end + + # Verify we received all the values + response_text = chunks.join + expect(response_text).to include("value1") + expect(response_text).to include("value2") + expect(response_text).to include("value3") + end + + it "streams bidirectionally - each_chunk receives chunks while async_props_block is still running" do + # Upload bundles first + ReactOnRailsPro::Request.upload_assets + + # Construct the incremental render path + js_code = "ReactOnRails.getStreamValues()" + request_digest = Digest::MD5.hexdigest(js_code) + render_path = "/bundles/#{server_bundle_hash}/incremental-render/#{request_digest}" + + # Single condition to signal when each chunk is received + chunk_received = Async::Condition.new + + # Wrap the test in a timeout to prevent hanging forever on deadlock + Timeout.timeout(10) do + # Perform incremental rendering with bidirectional verification + stream = ReactOnRailsPro::Request.render_code_with_incremental_updates( + render_path, + js_code, + async_props_block: proc { |emitter| + # Send first value and wait for confirmation + emitter.call("prop1", "value1") + chunk_received.wait + + # Send second value and wait for confirmation + emitter.call("prop2", "value2") + chunk_received.wait + + # Send third value and wait for confirmation + emitter.call("prop3", "value3") + chunk_received.wait + + # If we reach here, all chunks were received while async_block was running + }, + is_rsc_payload: false + ) + + # Collect chunks and signal after each one + chunks = [] + stream.each_chunk do |chunk| + chunks << chunk + chunk_received.signal + end + + # Verify all values were received + response_text = chunks.join + expect(response_text).to include("value1") + expect(response_text).to include("value2") + expect(response_text).to include("value3") + + # If this test completes without deadlock, it proves bidirectional streaming: + # - async_props_block sent chunks and waited for confirmation + # - each_chunk received chunks and signaled back while async_props_block was still running + # - This would deadlock if chunks weren't received concurrently + end + end + end +end From 6b38cd8c96e2d2c9e801c17ec81bf1cb8b39ca15 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 24 Nov 2025 18:52:30 +0200 Subject: [PATCH 9/9] Add test for incremental rendering with AsyncPropsComponent --- .../spec/dummy/app/controllers/pages_controller.rb | 4 ++++ .../views/pages/test_incremental_rendering.html.erb | 11 +++++++++++ react_on_rails_pro/spec/dummy/config/routes.rb | 1 + 3 files changed, 16 insertions(+) create mode 100644 react_on_rails_pro/spec/dummy/app/views/pages/test_incremental_rendering.html.erb diff --git a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb index 7a33950f91..70d94093c3 100644 --- a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb +++ b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb @@ -41,6 +41,10 @@ def stream_async_components_for_testing stream_view_containing_react_components(template: "/pages/stream_async_components_for_testing") end + def test_incremental_rendering + stream_view_containing_react_components(template: "/pages/test_incremental_rendering") + end + def cached_stream_async_components_for_testing stream_view_containing_react_components(template: "/pages/cached_stream_async_components_for_testing") end diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/test_incremental_rendering.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/test_incremental_rendering.html.erb new file mode 100644 index 0000000000..d0483c8e7b --- /dev/null +++ b/react_on_rails_pro/spec/dummy/app/views/pages/test_incremental_rendering.html.erb @@ -0,0 +1,11 @@ +

Incremental Rendering Test

+

Testing AsyncPropsComponent with incremental rendering

+ +<%= stream_react_component_with_async_props("AsyncPropsComponent", props: { name: "John Doe", age: 30, description: "Software Engineer" }) do |emit| + # Simulate fetching async props + sleep 1 + emit.call("books", ["The Pragmatic Programmer", "Clean Code", "Design Patterns"]) + + sleep 1 + emit.call("researches", ["Machine Learning Study", "React Performance Optimization", "Database Indexing Strategies"]) +end %> diff --git a/react_on_rails_pro/spec/dummy/config/routes.rb b/react_on_rails_pro/spec/dummy/config/routes.rb index 1d2f2b4e0d..dba3ad4c12 100644 --- a/react_on_rails_pro/spec/dummy/config/routes.rb +++ b/react_on_rails_pro/spec/dummy/config/routes.rb @@ -27,6 +27,7 @@ as: :stream_async_components_for_testing get "cached_stream_async_components_for_testing" => "pages#cached_stream_async_components_for_testing", as: :cached_stream_async_components_for_testing + get "test_incremental_rendering" => "pages#test_incremental_rendering", as: :test_incremental_rendering get "stream_async_components_for_testing_client_render" => "pages#stream_async_components_for_testing_client_render", as: :stream_async_components_for_testing_client_render get "rsc_posts_page_over_http" => "pages#rsc_posts_page_over_http", as: :rsc_posts_page_over_http