Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
eac0c28
add concurrent_stream_drain flag (default false)
ihabadham Aug 26, 2025
03da5bf
add and bundle async runtime dependency
ihabadham Aug 26, 2025
343fd4b
concurrent fiber draining via Async with single writer; add tracing l…
ihabadham Aug 26, 2025
49e73e2
make sequential draining robust to already finished fibers
ihabadham Aug 26, 2025
b285d38
add default backpressure via Async::Semaphore and handle client disco…
ihabadham Aug 26, 2025
355157a
add controller streaming specs for sequential vs concurrent, ordering…
ihabadham Aug 26, 2025
7767cdc
add a test for backpressure
ihabadham Aug 26, 2025
0c493b4
refactor to correct rubocop offenses
ihabadham Aug 26, 2025
fd36e2d
fix NoMethodError caused by Array.bytesize
ihabadham Aug 26, 2025
23c8cd5
add concurrent_stream_queue_capacity (default 64) and use it in strea…
ihabadham Aug 29, 2025
57a617d
add a comment explaining why semaphore.acquire is preferable to semap…
ihabadham Aug 29, 2025
54fdf01
refactor(stream): use Async::Queue#close as a final single sentinel; …
ihabadham Aug 29, 2025
25c0ae2
refactor: propagate streaming errors instead of rescuing
ihabadham Aug 29, 2025
c70d8b3
ci: correct rubocop offenses
ihabadham Sep 1, 2025
ae61932
add a simpler test for the concurrent stream_view_containing_react_co…
AbanoubGhadban Sep 3, 2025
85cb20c
refactor streaming tests to use pure mock approach
ihabadham Sep 7, 2025
be5f5dd
DRY the tests
ihabadham Sep 7, 2025
b6479c1
remove the concurrent_stream_drain config flag and always stream comp…
ihabadham Sep 7, 2025
f5f0f5a
remove debug logging
ihabadham Sep 7, 2025
bf320b6
correct rubocop offenses
ihabadham Sep 7, 2025
a7ed3cd
use async queue instead of ruby array at helper spec
AbanoubGhadban Sep 8, 2025
23b7eca
Revert "use async queue instead of ruby array at helper spec"
AbanoubGhadban Sep 8, 2025
a00d522
Enhance helper spec to support Async::Queue for chunk processing
AbanoubGhadban Sep 8, 2025
f4b8b0e
Revert "Enhance helper spec to support Async::Queue for chunk process…
AbanoubGhadban Sep 8, 2025
2e0c397
Refactor helper spec to utilize Async::Queue for improved chunk proce…
AbanoubGhadban Sep 8, 2025
dd5f321
Refactor configuration and streaming logic to use concurrent_componen…
AbanoubGhadban Sep 8, 2025
f9d493b
Refactor streaming logic to remove unnecessary error handling for imp…
AbanoubGhadban Sep 8, 2025
6cd4f07
pass buffer_size to LimitedQueue as a positional argument because it …
ihabadham Sep 8, 2025
cd301bc
ci: correct rubocop offenses
ihabadham Sep 8, 2025
7c8b9ad
ci: avoid getting a rubocop error
ihabadham Sep 8, 2025
3e13f98
update CHANGELOG.md
ihabadham Sep 8, 2025
91b203c
Update react_on_rails to 16.0.1.rc.4 to fix yanked version issue
ihabadham Sep 24, 2025
11c59d9
remove accidently pushed Gemfile.local.backup
ihabadham Sep 24, 2025
3bbc66f
git ignore .claude/
ihabadham Sep 24, 2025
f95d0f2
Fix ReactOnRails::PackerUtils.using_packer? compatibility with react_…
ihabadham Sep 24, 2025
a174d92
Remove redundant .claude/ entry from react_on_rails_pro/.gitignore
ihabadham Nov 13, 2025
de973fe
Add missing validation call for concurrent_component_streaming_buffer…
ihabadham Nov 13, 2025
38377bc
Validate buffer size as Integer instead of Numeric
ihabadham Nov 13, 2025
e9dd3ee
Add test for client disconnect cleanup behavior
github-actions[bot] Nov 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions react_on_rails_pro/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ yalc.lock

# React on Rails Pro License Key
config/react_on_rails_pro_license.key

.claude/
7 changes: 7 additions & 0 deletions react_on_rails_pro/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ You can find the **package** version numbers from this repo's tags and below in

_Add changes in master not yet tagged._

### Improved
- Significantly improved streaming performance by processing React components concurrently instead of sequentially. This reduces latency and improves responsiveness when using `stream_view_containing_react_components`.

### Added
- Added `config.concurrent_component_streaming_buffer_size` configuration option to control the memory buffer size for concurrent component streaming (defaults to 64). This allows fine-tuning of memory usage vs. performance for streaming applications.

### Added

- Added `cached_stream_react_component` helper method, similar to `cached_react_component` but for streamed components.
Expand Down Expand Up @@ -48,6 +54,7 @@ _Add changes in master not yet tagged._

- `config.prerender_caching`, which controls caching for non-streaming components, now also controls caching for streamed components. To disable caching for an individual render, pass `internal_option(:skip_prerender_cache)`.
- **Configuration Migration Required**: If you are using RSC features, you must move the RSC-related configurations from `ReactOnRails.configure` to `ReactOnRailsPro.configure` in your initializers. See the migration example in the [React on Rails CHANGELOG](https://github.com/shakacode/react_on_rails/blob/master/CHANGELOG.md#unreleased).
- Added `async` gem dependency (>= 2.6) to support concurrent streaming functionality.

## [4.0.0-rc.15] - 2025-08-11

Expand Down
18 changes: 18 additions & 0 deletions react_on_rails_pro/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ PATH
specs:
react_on_rails_pro (16.2.0.beta.4)
addressable
async (>= 2.6)
connection_pool
execjs (~> 2.9)
httpx (~> 1.5)
Expand Down Expand Up @@ -107,6 +108,12 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
amazing_print (1.6.0)
ast (2.4.2)
async (2.27.4)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.15)
base64 (0.2.0)
benchmark (0.4.0)
bigdecimal (3.1.9)
Expand Down Expand Up @@ -134,6 +141,10 @@ GEM
commonmarker (1.1.4-x86_64-linux)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
console (1.33.0)
fiber-annotation
fiber-local (~> 1.1)
json
coveralls (0.8.23)
json (>= 1.8, < 3)
simplecov (~> 0.16.1)
Expand All @@ -158,6 +169,10 @@ GEM
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
fiber-annotation (0.2.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.1)
gem-release (2.2.2)
generator_spec (0.10.0)
activesupport (>= 3.0.0)
Expand All @@ -173,6 +188,7 @@ GEM
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-console (0.8.0)
io-event (1.12.1)
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
Expand Down Expand Up @@ -205,6 +221,7 @@ GEM
marcel (1.0.4)
matrix (0.4.2)
method_source (1.1.0)
metrics (0.14.0)
mini_mime (1.1.5)
minitest (5.25.4)
mize (0.4.1)
Expand Down Expand Up @@ -411,6 +428,7 @@ GEM
tins (1.33.0)
bigdecimal
sync
traces (0.18.1)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
Expand Down
62 changes: 58 additions & 4 deletions react_on_rails_pro/lib/react_on_rails_pro/concerns/stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,66 @@ def stream_view_containing_react_components(template:, close_stream_at_end: true
# So we strip extra newlines from the template string and add a single newline
response.stream.write(template_string)

@rorp_rendering_fibers.each do |fiber|
while (chunk = fiber.resume)
response.stream.write(chunk)
begin
drain_streams_concurrently
ensure
response.stream.close if close_stream_at_end
end
end

private

def drain_streams_concurrently
require "async"
require "async/limited_queue"

return if @rorp_rendering_fibers.empty?

Sync do |parent|
# To avoid memory bloat, we use a limited queue to buffer chunks in memory.
buffer_size = ReactOnRailsPro.configuration.concurrent_component_streaming_buffer_size
queue = Async::LimitedQueue.new(buffer_size)

writer = build_writer_task(parent: parent, queue: queue)
tasks = build_producer_tasks(parent: parent, queue: queue)

# This structure ensures that even if a producer task fails, we always
# signal the writer to stop and then wait for it to finish draining
# any remaining items from the queue before propagating the error.
begin
tasks.each(&:wait)
ensure
# `close` signals end-of-stream; when writer tries to dequeue, it will get nil, so it will exit.
queue.close
writer.wait
end
end
end

def build_producer_tasks(parent:, queue:)
@rorp_rendering_fibers.each_with_index.map do |fiber, idx|
parent.async do
loop do
chunk = fiber.resume
break unless chunk

# Will be blocked if the queue is full until a chunk is dequeued
queue.enqueue([idx, chunk])
end
end
end
end

def build_writer_task(parent:, queue:)
parent.async do
loop do
pair = queue.dequeue
break if pair.nil?

_idx_from_queue, item = pair
response.stream.write(item)
end
end
response.stream.close if close_stream_at_end
end
end
end
19 changes: 16 additions & 3 deletions react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ def self.configuration
rsc_payload_generation_url_path: Configuration::DEFAULT_RSC_PAYLOAD_GENERATION_URL_PATH,
rsc_bundle_js_file: Configuration::DEFAULT_RSC_BUNDLE_JS_FILE,
react_client_manifest_file: Configuration::DEFAULT_REACT_CLIENT_MANIFEST_FILE,
react_server_client_manifest_file: Configuration::DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE
react_server_client_manifest_file: Configuration::DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE,
concurrent_component_streaming_buffer_size: Configuration::DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE
)
end

Expand All @@ -59,6 +60,7 @@ class Configuration # rubocop:disable Metrics/ClassLength
DEFAULT_RSC_BUNDLE_JS_FILE = "rsc-bundle.js"
DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json"
DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE = "react-server-client-manifest.json"
DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE = 64

attr_accessor :renderer_url, :renderer_password, :tracing,
:server_renderer, :renderer_use_fallback_exec_js, :prerender_caching,
Expand All @@ -68,7 +70,7 @@ class Configuration # rubocop:disable Metrics/ClassLength
:renderer_request_retry_limit, :throw_js_errors, :ssr_timeout,
:profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support,
:rsc_payload_generation_url_path, :rsc_bundle_js_file, :react_client_manifest_file,
:react_server_client_manifest_file
:react_server_client_manifest_file, :concurrent_component_streaming_buffer_size

def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, # rubocop:disable Metrics/AbcSize
renderer_use_fallback_exec_js: nil, prerender_caching: nil,
Expand All @@ -79,7 +81,9 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil,
renderer_request_retry_limit: nil, throw_js_errors: nil, ssr_timeout: nil,
profile_server_rendering_js_code: nil, raise_non_shell_server_rendering_errors: nil,
enable_rsc_support: nil, rsc_payload_generation_url_path: nil,
rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_client_manifest_file: nil)
rsc_bundle_js_file: nil, react_client_manifest_file: nil,
react_server_client_manifest_file: nil,
concurrent_component_streaming_buffer_size: DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE)
self.renderer_url = renderer_url
self.renderer_password = renderer_password
self.server_renderer = server_renderer
Expand All @@ -105,6 +109,7 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil,
self.rsc_bundle_js_file = rsc_bundle_js_file
self.react_client_manifest_file = react_client_manifest_file
self.react_server_client_manifest_file = react_server_client_manifest_file
self.concurrent_component_streaming_buffer_size = concurrent_component_streaming_buffer_size
end

def setup_config_values
Expand Down Expand Up @@ -204,6 +209,14 @@ def validate_remote_bundle_cache_adapter
end
end

def validate_concurrent_component_streaming_buffer_size
return if concurrent_component_streaming_buffer_size.is_a?(Numeric) &&
concurrent_component_streaming_buffer_size.positive?

raise ReactOnRailsPro::Error,
"config.concurrent_component_streaming_buffer_size must be set and must be a positive number"
end

def setup_renderer_password
return if renderer_password.present?

Expand Down
6 changes: 3 additions & 3 deletions react_on_rails_pro/lib/react_on_rails_pro/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def self.rsc_bundle_hash
@rsc_bundle_hash = calc_bundle_hash(server_rsc_bundle_js_file_path)
end

# Returns the hashed file name when using webpacker. Useful for creating cache keys.
# Returns the hashed file name when using Shakapacker. Useful for creating cache keys.
def self.bundle_file_name(bundle_name)
# bundle_js_uri_from_packer can return a file path or a HTTP URL (for files served from the dev server)
# Pathname can handle both cases
Expand All @@ -117,8 +117,8 @@ def self.bundle_file_name(bundle_name)
pathname.basename.to_s
end

# Returns the hashed file name of the server bundle when using webpacker.
# Necessary fragment-caching keys.
# Returns the hashed file name of the server bundle when using Shakapacker.
# Necessary for fragment-caching keys.
def self.server_bundle_file_name
return @server_bundle_hash if @server_bundle_hash && !Rails.env.development?

Expand Down
1 change: 1 addition & 0 deletions react_on_rails_pro/react_on_rails_pro.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Gem::Specification.new do |s|
s.add_runtime_dependency "execjs", "~> 2.9"
s.add_runtime_dependency "httpx", "~> 1.5"
s.add_runtime_dependency "jwt", "~> 2.7"
s.add_runtime_dependency "async", ">= 2.6"
s.add_runtime_dependency "rainbow"
s.add_runtime_dependency "react_on_rails", ReactOnRails::VERSION
s.add_development_dependency "bundler"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "async"
require "async/queue"
require "rails_helper"
require "support/script_tag_utils"

Expand Down Expand Up @@ -327,6 +329,7 @@ def response; end
HTML
end

# mock_chunks can be an Async::Queue or an Array
def mock_request_and_response(mock_chunks = chunks, count: 1)
# Reset connection instance variables to ensure clean state for tests
ReactOnRailsPro::Request.instance_variable_set(:@connection, nil)
Expand All @@ -340,9 +343,19 @@ def mock_request_and_response(mock_chunks = chunks, count: 1)
chunks_read.clear
mock_streaming_response(%r{http://localhost:3800/bundles/[a-f0-9]{32}-test/render/[a-f0-9]{32}}, 200,
count: count) do |yielder|
mock_chunks.each do |chunk|
chunks_read << chunk
yielder.call("#{chunk.to_json}\n")
if mock_chunks.is_a?(Async::Queue)
loop do
chunk = mock_chunks.dequeue
break if chunk.nil?

chunks_read << chunk
yielder.call("#{chunk.to_json}\n")
end
else
mock_chunks.each do |chunk|
chunks_read << chunk
yielder.call("#{chunk.to_json}\n")
end
end
end
end
Expand Down Expand Up @@ -429,18 +442,35 @@ def mock_request_and_response(mock_chunks = chunks, count: 1)

allow(mocked_stream).to receive(:write) do |chunk|
written_chunks << chunk
# Ensures that any chunk received is written immediately to the stream
expect(written_chunks.count).to eq(chunks_read.count) # rubocop:disable RSpec/ExpectInHook
end
allow(mocked_stream).to receive(:close)
mocked_response = instance_double(ActionDispatch::Response)
allow(mocked_response).to receive(:stream).and_return(mocked_stream)
allow(self).to receive(:response).and_return(mocked_response)
mock_request_and_response
end

def execute_stream_view_containing_react_components
queue = Async::Queue.new
mock_request_and_response(queue)

Sync do |parent|
parent.async { stream_view_containing_react_components(template: template_path) }

chunks_to_write = chunks.dup
while (chunk = chunks_to_write.shift)
queue.enqueue(chunk)
sleep 0.05

# Ensures that any chunk received is written immediately to the stream
expect(written_chunks.count).to eq(chunks_read.count)
end
queue.close
sleep 0.05
end
end

it "writes the chunk to stream as soon as it is received" do
stream_view_containing_react_components(template: template_path)
execute_stream_view_containing_react_components
expect(self).to have_received(:render_to_string).once.with(template: template_path)
expect(chunks_read.count).to eq(chunks.count)
expect(written_chunks.count).to eq(chunks.count)
Expand All @@ -449,7 +479,7 @@ def mock_request_and_response(mock_chunks = chunks, count: 1)
end

it "prepends the rails context to the first chunk only" do
stream_view_containing_react_components(template: template_path)
execute_stream_view_containing_react_components
initial_result = written_chunks.first
expect(initial_result).to script_tag_be_included(rails_context_tag)

Expand All @@ -465,7 +495,7 @@ def mock_request_and_response(mock_chunks = chunks, count: 1)
end

it "prepends the component specification tag to the first chunk only" do
stream_view_containing_react_components(template: template_path)
execute_stream_view_containing_react_components
initial_result = written_chunks.first
expect(initial_result).to script_tag_be_included(react_component_specification_tag)

Expand All @@ -476,7 +506,7 @@ def mock_request_and_response(mock_chunks = chunks, count: 1)
end

it "renders the rails view content in the first chunk" do
stream_view_containing_react_components(template: template_path)
execute_stream_view_containing_react_components
initial_result = written_chunks.first
expect(initial_result).to include("<h1>Header Rendered In View</h1>")
written_chunks[1..].each do |chunk|
Expand Down
Loading
Loading