Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
109cc51
add concurrent_stream_drain flag (default false)
ihabadham Aug 26, 2025
6b18fbb
add and bundle async runtime dependency
ihabadham Aug 26, 2025
e8af706
concurrent fiber draining via Async with single writer; add tracing l…
ihabadham Aug 26, 2025
8702395
make sequential draining robust to already finished fibers
ihabadham Aug 26, 2025
8daf9e9
add default backpressure via Async::Semaphore and handle client disco…
ihabadham Aug 26, 2025
20844f3
add controller streaming specs for sequential vs concurrent, ordering…
ihabadham Aug 26, 2025
dc15f8f
add a test for backpressure
ihabadham Aug 26, 2025
12be617
refactor to correct rubocop offenses
ihabadham Aug 26, 2025
33f9ca7
fix NoMethodError caused by Array.bytesize
ihabadham Aug 26, 2025
0f1bc95
add concurrent_stream_queue_capacity (default 64) and use it in strea…
ihabadham Aug 29, 2025
ed1bf71
add a comment explaining why semaphore.acquire is preferable to semap…
ihabadham Aug 29, 2025
3cdabbf
refactor(stream): use Async::Queue#close as a final single sentinel; …
ihabadham Aug 29, 2025
392d09c
refactor: propagate streaming errors instead of rescuing
ihabadham Aug 29, 2025
93c5a58
ci: correct rubocop offenses
ihabadham Sep 1, 2025
e3c1c25
add a simpler test for the concurrent stream_view_containing_react_co…
AbanoubGhadban Sep 3, 2025
4fd58bc
refactor streaming tests to use pure mock approach
ihabadham Sep 7, 2025
3f8d4f6
DRY the tests
ihabadham Sep 7, 2025
94a48af
remove the concurrent_stream_drain config flag and always stream comp…
ihabadham Sep 7, 2025
fce26ba
remove debug logging
ihabadham Sep 7, 2025
2bfdeb1
correct rubocop offenses
ihabadham Sep 7, 2025
7a8f2cd
use async queue instead of ruby array at helper spec
AbanoubGhadban Sep 8, 2025
b90eb98
Revert "use async queue instead of ruby array at helper spec"
AbanoubGhadban Sep 8, 2025
fb12126
Enhance helper spec to support Async::Queue for chunk processing
AbanoubGhadban Sep 8, 2025
05946ce
Revert "Enhance helper spec to support Async::Queue for chunk process…
AbanoubGhadban Sep 8, 2025
f5d0307
Refactor helper spec to utilize Async::Queue for improved chunk proce…
AbanoubGhadban Sep 8, 2025
6abf454
Refactor configuration and streaming logic to use concurrent_componen…
AbanoubGhadban Sep 8, 2025
db5ef8f
Refactor streaming logic to remove unnecessary error handling for imp…
AbanoubGhadban Sep 8, 2025
863b29b
pass buffer_size to LimitedQueue as a positional argument because it …
ihabadham Sep 8, 2025
f25450c
ci: correct rubocop offenses
ihabadham Sep 8, 2025
3d0935a
ci: avoid getting a rubocop error
ihabadham Sep 8, 2025
b7a0d3e
update CHANGELOG.md
ihabadham Sep 8, 2025
67e830b
Update react_on_rails to 16.0.1.rc.4 to fix yanked version issue
ihabadham Sep 24, 2025
2f8b08b
remove accidently pushed Gemfile.local.backup
ihabadham Sep 24, 2025
1e56cdc
git ignore .claude/
ihabadham Sep 24, 2025
9a9ae35
Update react-on-rails to 16.0.1-rc.4 in package.json files
ihabadham Sep 24, 2025
47a4341
forgot to update this yarn.lock
ihabadham Sep 24, 2025
c27d381
Fix ReactOnRails::PackerUtils.using_packer? compatibility with react_…
ihabadham Sep 24, 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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,5 @@ yalc.lock

# File Generated by ROR FS-based Registry
**/generated

.claude/
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@ You can find the **package** version numbers from this repo's tags and below in
## [Unreleased]
*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.

### Changed (Breaking)
- `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)`.
- 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 Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ PATH
specs:
react_on_rails_pro (4.0.0)
addressable
async (>= 2.6)
connection_pool
execjs (~> 2.9)
httpx (~> 1.5)
Expand Down Expand Up @@ -95,6 +96,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 @@ -122,6 +129,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 @@ -146,6 +157,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 @@ -161,6 +176,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 @@ -191,6 +207,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 @@ -402,6 +419,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 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
15 changes: 13 additions & 2 deletions lib/react_on_rails_pro/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class Configuration # rubocop:disable Metrics/ClassLength
DEFAULT_RAISE_NON_SHELL_SERVER_RENDERING_ERRORS = false
DEFAULT_ENABLE_RSC_SUPPORT = false
DEFAULT_RSC_PAYLOAD_GENERATION_URL_PATH = "rsc_payload/"
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 @@ -61,7 +62,7 @@ class Configuration # rubocop:disable Metrics/ClassLength
:remote_bundle_cache_adapter, :ssr_pre_hook_js, :assets_to_copy,
: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_payload_generation_url_path, :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 @@ -71,7 +72,8 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil,
remote_bundle_cache_adapter: nil, ssr_pre_hook_js: nil, assets_to_copy: 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)
enable_rsc_support: nil, rsc_payload_generation_url_path: 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 @@ -94,6 +96,7 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil,
self.raise_non_shell_server_rendering_errors = raise_non_shell_server_rendering_errors
self.enable_rsc_support = enable_rsc_support
self.rsc_payload_generation_url_path = rsc_payload_generation_url_path
self.concurrent_component_streaming_buffer_size = concurrent_component_streaming_buffer_size
end

def setup_config_values
Expand Down Expand Up @@ -193,6 +196,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 lib/react_on_rails_pro/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,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 @@ -74,8 +74,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.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Gem::Specification.new do |s|
s.add_runtime_dependency "connection_pool"
s.add_runtime_dependency "execjs", "~> 2.9"
s.add_runtime_dependency "httpx", "~> 1.5"
s.add_runtime_dependency "async", ">= 2.6"
s.add_runtime_dependency "rainbow"
s.add_runtime_dependency "react_on_rails", ">= 16.0.0"
s.add_development_dependency "bundler"
Expand Down
50 changes: 40 additions & 10 deletions spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb
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 @@ -339,9 +342,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 @@ -428,18 +441,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 @@ -448,7 +478,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 @@ -464,7 +494,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 @@ -475,7 +505,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
2 changes: 1 addition & 1 deletion spec/execjs-compatible-dummy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-on-rails": "15.0.0",
"react-on-rails": "16.0.1-rc.4",
"shakapacker": "8.0.0",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "5",
Expand Down
8 changes: 4 additions & 4 deletions spec/execjs-compatible-dummy/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3365,10 +3365,10 @@ react-is@^16.13.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==

react-on-rails@15.0.0:
version "15.0.0"
resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-15.0.0.tgz#8edc78670129394cd92293842ae88c62d4cb030b"
integrity sha512-Uht8HWm8ZVN8OeDz03b3YLtprzHXhFTw7hb0jl8IpOjbig91DBuurBVxr4tfuymuemGwdjhQpscho33Cnw9Atg==
react-on-rails@16.0.1-rc.4:
version "16.0.1-rc.4"
resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-16.0.1-rc.4.tgz#c4ed21579dbf8d4602f1fbff26b1ada581f2188c"
integrity sha512-V6unOWd/PvcbEcgtsjf/i10wf2BgZ0NBJ9Dq5jWXm9OtlzRGvIqMXsK05YkyJ1auPlaExxxJezNPC+BQKLRhEg==

react-refresh@^0.14.2:
version "0.14.2"
Expand Down
Loading