From 5d78a838db309329b3eb423ed86b5edfc70a02c4 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 27 Nov 2025 16:54:27 +0200 Subject: [PATCH 1/5] Add async_react_component and cached_async_react_component helpers (#2138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add concurrent React component rendering support for React on Rails Pro. Multiple async_react_component calls now execute their HTTP rendering requests in parallel instead of sequentially. New features: - async_react_component: Returns AsyncValue immediately, renders concurrently - cached_async_react_component: Async rendering with fragment caching support - AsyncRendering concern: Controller mixin with enable_async_react_rendering - Supports all cached_react_component options (:cache_key, :cache_options, :if, :unless) Usage: ```ruby class ProductsController < ApplicationController include ReactOnRailsPro::AsyncRendering enable_async_react_rendering only: [:show] end ``` ```erb <% header = async_react_component("Header", props: @header_props) %> <% sidebar = async_react_component("Sidebar", props: @sidebar_props) %> <%= header.value %> <%= sidebar.value %> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/helpers/react_on_rails_pro_helper.rb | 123 ++++++++++++ react_on_rails_pro/lib/react_on_rails_pro.rb | 3 + .../lib/react_on_rails_pro/async_value.rb | 38 ++++ .../concerns/async_rendering.rb | 53 ++++++ .../immediate_async_value.rb | 27 +++ .../helpers/react_on_rails_pro_helper_spec.rb | 176 ++++++++++++++++++ .../react_on_rails_pro/async_value_spec.rb | 101 ++++++++++ .../immediate_async_value_spec.rb | 46 +++++ 8 files changed, 567 insertions(+) create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/async_value.rb create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb create mode 100644 react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb create mode 100644 react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb create mode 100644 react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb 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..1c38cf8664 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 @@ -217,6 +217,63 @@ def cached_stream_react_component(component_name, raw_options = {}, &block) end end + # Renders a React component asynchronously, returning an AsyncValue immediately. + # Multiple async_react_component calls will execute their HTTP rendering requests + # concurrently instead of sequentially. + # + # Requires the controller to include ReactOnRailsPro::AsyncRendering and call + # enable_async_react_rendering. + # + # @param component_name [String] Name of your registered component + # @param options [Hash] Same options as react_component + # @return [ReactOnRailsPro::AsyncValue] Call .value to get the rendered HTML + # + # @example + # <% header = async_react_component("Header", props: @header_props) %> + # <% sidebar = async_react_component("Sidebar", props: @sidebar_props) %> + # <%= header.value %> + # <%= sidebar.value %> + # + def async_react_component(component_name, options = {}) + unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier + raise ReactOnRailsPro::Error, + "async_react_component requires AsyncRendering concern. " \ + "Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering." + end + + task = @react_on_rails_async_barrier.async do + react_component(component_name, options) + end + + ReactOnRailsPro::AsyncValue.new(component_name: component_name, task: task) + end + + # Renders a React component asynchronously with caching support. + # Cache lookup is synchronous - cache hits return immediately without async. + # Cache misses trigger async render and cache the result on completion. + # + # All the same options as cached_react_component apply: + # 1. You must pass the props as a block (evaluated only on cache miss) + # 2. Provide the cache_key option + # 3. Optionally provide :cache_options for Rails.cache (expires_in, etc.) + # 4. Provide :if or :unless for conditional caching + # + # @param component_name [String] Name of your registered component + # @param options [Hash] Options including cache_key and cache_options + # @yield Block that returns props (evaluated only on cache miss) + # @return [ReactOnRailsPro::AsyncValue, ReactOnRailsPro::ImmediateAsyncValue] + # + # @example + # <% card = cached_async_react_component("ProductCard", cache_key: @product) { @product.to_props } %> + # <%= card.value %> + # + def cached_async_react_component(component_name, raw_options = {}, &block) + ReactOnRailsPro::Utils.with_trace(component_name) do + check_caching_options!(raw_options, block) + fetch_async_react_component(component_name, raw_options, &block) + end + end + if defined?(ScoutApm) include ScoutApm::Tracer instrument_method :cached_react_component, type: "ReactOnRails", name: "cached_react_component" @@ -298,6 +355,72 @@ def check_caching_options!(raw_options, block) raise ReactOnRailsPro::Error, "Option 'cache_key' is required for React on Rails caching" end + # Async version of fetch_react_component. Handles cache lookup synchronously, + # returns ImmediateAsyncValue on hit, AsyncValue on miss. + def fetch_async_react_component(component_name, raw_options, &block) + unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier + raise ReactOnRailsPro::Error, + "cached_async_react_component requires AsyncRendering concern. " \ + "Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering." + end + + # Check conditional caching (:if / :unless options) + unless ReactOnRailsPro::Cache.use_cache?(raw_options) + return render_async_react_component_uncached(component_name, raw_options, &block) + end + + cache_key = ReactOnRailsPro::Cache.react_component_cache_key(component_name, raw_options) + cache_options = raw_options[:cache_options] || {} + Rails.logger.debug { "React on Rails Pro async cache_key is #{cache_key.inspect}" } + + # Synchronous cache lookup + cached_result = Rails.cache.read(cache_key, cache_options) + if cached_result + Rails.logger.debug { "React on Rails Pro async cache HIT for #{cache_key.inspect}" } + render_options = ReactOnRails::ReactComponent::RenderOptions.new( + react_component_name: component_name, + options: raw_options + ) + load_pack_for_generated_component(component_name, render_options) + return ReactOnRailsPro::ImmediateAsyncValue.new(cached_result) + end + + Rails.logger.debug { "React on Rails Pro async cache MISS for #{cache_key.inspect}" } + render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block) + end + + # Renders async without caching (when :if/:unless conditions disable cache) + def render_async_react_component_uncached(component_name, raw_options, &block) + options = prepare_async_render_options(raw_options, &block) + + task = @react_on_rails_async_barrier.async do + react_component(component_name, options) + end + + ReactOnRailsPro::AsyncValue.new(component_name: component_name, task: task) + end + + # Renders async and writes to cache on completion + def render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block) + options = prepare_async_render_options(raw_options, &block) + + task = @react_on_rails_async_barrier.async do + result = react_component(component_name, options) + Rails.cache.write(cache_key, result, cache_options) + result + end + + ReactOnRailsPro::AsyncValue.new(component_name: component_name, task: task) + end + + def prepare_async_render_options(raw_options) + raw_options.merge( + props: yield, + skip_prerender_cache: true, + auto_load_bundle: ReactOnRails.configuration.auto_load_bundle || raw_options[:auto_load_bundle] + ) + end + def consumer_stream_async(on_complete:) require "async/variable" 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..b7695b028d 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro.rb @@ -20,4 +20,7 @@ require "react_on_rails_pro/prepare_node_renderer_bundles" require "react_on_rails_pro/concerns/stream" require "react_on_rails_pro/concerns/rsc_payload_renderer" +require "react_on_rails_pro/concerns/async_rendering" +require "react_on_rails_pro/async_value" +require "react_on_rails_pro/immediate_async_value" require "react_on_rails_pro/routes" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb new file mode 100644 index 0000000000..882b4d9694 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # AsyncValue wraps an Async task to provide a simple interface for + # retrieving the result of an async react_component render. + # + # @example + # async_value = async_react_component("MyComponent", props: { name: "World" }) + # # ... do other work ... + # html = async_value.value # blocks until result is ready + # + class AsyncValue + attr_reader :component_name + + def initialize(component_name:, task:) + @component_name = component_name + @task = task + end + + # Blocks until result is ready, returns HTML string. + # If the async task raised an exception, it will be re-raised here. + def value + @task.wait + end + + def resolved? + @task.finished? + end + + def to_s + value.to_s + end + + def html_safe + value.html_safe + end + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb new file mode 100644 index 0000000000..e0a8594d03 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # AsyncRendering enables concurrent rendering of multiple React components. + # When enabled, async_react_component calls will execute their HTTP requests + # in parallel instead of sequentially. + # + # @example Enable for all actions + # class ProductsController < ApplicationController + # include ReactOnRailsPro::AsyncRendering + # enable_async_react_rendering + # end + # + # @example Enable for specific actions only + # class ProductsController < ApplicationController + # include ReactOnRailsPro::AsyncRendering + # enable_async_react_rendering only: [:show, :index] + # end + # + # @example Enable for all except specific actions + # class ProductsController < ApplicationController + # include ReactOnRailsPro::AsyncRendering + # enable_async_react_rendering except: [:create, :update] + # end + # + module AsyncRendering + extend ActiveSupport::Concern + + class_methods do + # Enables async React component rendering for controller actions. + # Accepts standard Rails filter options like :only and :except. + # + # @param options [Hash] Options passed to around_action (e.g., only:, except:) + def enable_async_react_rendering(**options) + around_action :wrap_in_async_react_context, **options + end + end + + private + + def wrap_in_async_react_context + require "async" + require "async/barrier" + + Sync do + @react_on_rails_async_barrier = Async::Barrier.new + yield + ensure + @react_on_rails_async_barrier = nil + end + end + end +end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb b/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb new file mode 100644 index 0000000000..1282b105bf --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ReactOnRailsPro + # ImmediateAsyncValue is returned when a cached_async_react_component call + # has a cache hit. It provides the same interface as AsyncValue but returns + # the cached value immediately without any async operations. + # + class ImmediateAsyncValue + def initialize(value) + @value = value + end + + attr_reader :value + + def resolved? + true + end + + def to_s + @value.to_s + end + + def html_safe + @value.html_safe + end + end +end diff --git a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb index e8b90fd01f..5c6ccceb77 100644 --- a/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +++ b/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb @@ -816,5 +816,181 @@ def render_cached_random_value(cache_key) expect(comment_count).to eq(1) end end + + describe "#async_react_component", :requires_webpack_assets do + context "without async context" do + it "raises an error when called outside async context" do + expect do + async_react_component("App", props: { a: 1 }) + end.to raise_error(ReactOnRailsPro::Error, /AsyncRendering concern/) + end + end + + context "with async context" do + around do |example| + Sync do + @react_on_rails_async_barrier = Async::Barrier.new + example.run + ensure + @react_on_rails_async_barrier = nil + end + end + + it "returns an AsyncValue" do + result = async_react_component("App", props: { a: 1 }) + expect(result).to be_a(ReactOnRailsPro::AsyncValue) + end + + it "renders the component when value is accessed" do + async_value = async_react_component("App", props: { a: 1, b: 2 }) + html = async_value.value + + expect(html).to include('id="App-react-component') + end + + it "executes multiple components concurrently" do + call_count = 0 + max_concurrent = 0 + mutex = Mutex.new + + allow(self).to receive(:react_component) do |_name, _opts| + mutex.synchronize do + call_count += 1 + max_concurrent = [max_concurrent, call_count].max + end + + # Yield to other fibers to allow concurrent execution + Async::Task.current.yield + + mutex.synchronize { call_count -= 1 } + "
rendered
" + end + + value1 = async_react_component("App", props: { a: 1 }) + value2 = async_react_component("App", props: { b: 2 }) + + value1.value + value2.value + + # If concurrent, both calls should have been active at the same time + expect(max_concurrent).to eq(2) + expect(call_count).to eq(0) + end + + it "re-raises exceptions from react_component" do + allow(self).to receive(:react_component).and_raise(StandardError, "Render error") + + async_value = async_react_component("BadComponent", props: {}) + + expect { async_value.value }.to raise_error(StandardError, "Render error") + end + end + end + + describe "#cached_async_react_component", :caching, :requires_webpack_assets do + context "without async context" do + it "raises an error when called outside async context" do + expect do + cached_async_react_component("App", cache_key: "test") { { a: 1 } } + end.to raise_error(ReactOnRailsPro::Error, /AsyncRendering concern/) + end + end + + context "with async context" do + around do |example| + Sync do + @react_on_rails_async_barrier = Async::Barrier.new + example.run + ensure + @react_on_rails_async_barrier = nil + end + end + + it "returns an AsyncValue on cache miss" do + result = cached_async_react_component("App", cache_key: "async-test-miss") { { a: 1 } } + expect(result).to be_a(ReactOnRailsPro::AsyncValue) + end + + it "returns an ImmediateAsyncValue on cache hit" do + # First call - cache miss + first_result = cached_async_react_component("App", cache_key: "async-test-hit") { { a: 1 } } + first_result.value # Wait for render and cache write + + # Second call - cache hit + second_result = cached_async_react_component("App", cache_key: "async-test-hit") { { a: 1 } } + expect(second_result).to be_a(ReactOnRailsPro::ImmediateAsyncValue) + end + + it "caches the rendered component" do + cache_key = "async-cache-test-#{SecureRandom.hex(4)}" + + # First render + first_value = cached_async_react_component("RandomValue", cache_key: cache_key) { { a: 1 } } + first_html = first_value.value + + # Second render should return cached content + second_value = cached_async_react_component("RandomValue", cache_key: cache_key) { { a: 1 } } + second_html = second_value.value + + expect(second_html).to eq(first_html) + end + + it "doesn't call the block on cache hit" do + cache_key = "async-block-test-#{SecureRandom.hex(4)}" + + # Prime the cache + first_value = cached_async_react_component("App", cache_key: cache_key) { { a: 1 } } + first_value.value + + # Second call should not yield + expect do |block| + cached_async_react_component("App", cache_key: cache_key, &block) + end.not_to yield_control + end + + it "respects :if option for conditional caching" do + cache_key = "async-if-test-#{SecureRandom.hex(4)}" + + # With if: false, should not cache + first_value = cached_async_react_component("RandomValue", cache_key: cache_key, if: false) { { a: 1 } } + first_html = first_value.value + + second_value = cached_async_react_component("RandomValue", cache_key: cache_key, if: false) { { a: 1 } } + second_html = second_value.value + + # Both should be AsyncValue (not ImmediateAsyncValue) since caching is disabled + expect(first_value).to be_a(ReactOnRailsPro::AsyncValue) + expect(second_value).to be_a(ReactOnRailsPro::AsyncValue) + + # RandomValue generates different values each render when not cached + expect(second_html).not_to eq(first_html) + end + + it "respects :unless option for conditional caching" do + cache_key = "async-unless-test-#{SecureRandom.hex(4)}" + + # With unless: true, should not cache + first_value = cached_async_react_component("RandomValue", cache_key: cache_key, unless: true) { { a: 1 } } + first_html = first_value.value + + second_value = cached_async_react_component("RandomValue", cache_key: cache_key, unless: true) { { a: 1 } } + second_html = second_value.value + + expect(second_html).not_to eq(first_html) + end + + it "raises error when props are passed directly instead of as block" do + expect do + cached_async_react_component("App", cache_key: "test", props: { a: 1 }) + end.to raise_error(ReactOnRailsPro::Error, /Pass 'props' as a block/) + end + + it "raises error when cache_key is missing" do + expect do + cached_async_react_component("App") { { a: 1 } } + end.to raise_error(ReactOnRailsPro::Error, /cache_key.*required/) + end + end + end end # rubocop:enable RSpec/InstanceVariable diff --git a/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb new file mode 100644 index 0000000000..9fc14f44aa --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require "async" +require "async/barrier" + +module ReactOnRailsPro + RSpec.describe AsyncValue do + describe "#initialize" do + it "stores the component name and task" do + task = instance_double(Async::Task) + async_value = described_class.new(component_name: "MyComponent", task: task) + + expect(async_value.component_name).to eq("MyComponent") + end + end + + describe "#value" do + it "returns the task result when task completes successfully" do + Sync do + task = Async do + "
Hello
" + end + + async_value = described_class.new(component_name: "MyComponent", task: task) + expect(async_value.value).to eq("
Hello
") + end + end + + it "re-raises exception when task fails" do + Sync do + task = Async do + raise StandardError, "Render failed" + end + + async_value = described_class.new(component_name: "MyComponent", task: task) + expect { async_value.value }.to raise_error(StandardError, "Render failed") + end + end + end + + describe "#resolved?" do + it "returns false when task is not finished" do + Sync do + barrier = Async::Barrier.new + + task = barrier.async do + sleep 0.1 + "result" + end + + async_value = described_class.new(component_name: "MyComponent", task: task) + expect(async_value.resolved?).to be false + + barrier.wait + end + end + + it "returns true when task is finished" do + Sync do + task = Async do + "result" + end + + task.wait + async_value = described_class.new(component_name: "MyComponent", task: task) + expect(async_value.resolved?).to be true + end + end + end + + describe "#to_s" do + it "returns the string representation of the value" do + Sync do + task = Async do + "
Content
" + end + + async_value = described_class.new(component_name: "MyComponent", task: task) + expect(async_value.to_s).to eq("
Content
") + end + end + end + + describe "#html_safe" do + it "returns the html_safe version of the value" do + Sync do + task = Async do + "
Content
" + end + + async_value = described_class.new(component_name: "MyComponent", task: task) + result = async_value.html_safe + + expect(result).to be_html_safe + expect(result).to eq("
Content
") + end + end + end + end +end diff --git a/react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb new file mode 100644 index 0000000000..0a4aa7f15c --- /dev/null +++ b/react_on_rails_pro/spec/react_on_rails_pro/immediate_async_value_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +module ReactOnRailsPro + RSpec.describe ImmediateAsyncValue do + describe "#initialize" do + it "stores the value" do + immediate_value = described_class.new("
Cached
") + expect(immediate_value.value).to eq("
Cached
") + end + end + + describe "#value" do + it "returns the stored value immediately" do + immediate_value = described_class.new("
Cached Content
") + expect(immediate_value.value).to eq("
Cached Content
") + end + end + + describe "#resolved?" do + it "always returns true" do + immediate_value = described_class.new("any value") + expect(immediate_value.resolved?).to be true + end + end + + describe "#to_s" do + it "returns the string representation of the value" do + immediate_value = described_class.new("
Content
") + expect(immediate_value.to_s).to eq("
Content
") + end + end + + describe "#html_safe" do + it "returns the html_safe version of the value" do + html_content = "
Content
" + immediate_value = described_class.new(html_content) + result = immediate_value.html_safe + + expect(result).to be_html_safe + expect(result).to eq("
Content
") + end + end + end +end From 8aaf212fb0c97294a895a46b094d113daea198c4 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 27 Nov 2025 17:13:12 +0200 Subject: [PATCH 2/5] Add async components demo with concurrent rendering example --- .../concerns/async_rendering.rb | 15 ++++++++ .../dummy/app/controllers/pages_controller.rb | 9 +++++ .../pages/pro/async_components_demo.html.erb | 34 +++++++++++++++++++ .../DelayedComponent.client.jsx | 12 +++++++ .../DelayedComponent.server.jsx | 21 ++++++++++++ .../spec/dummy/config/routes.rb | 1 + 6 files changed, 92 insertions(+) create mode 100644 react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb create mode 100644 react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx create mode 100644 react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx diff --git a/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb index e0a8594d03..ee5de8649f 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb @@ -45,9 +45,24 @@ def wrap_in_async_react_context Sync do @react_on_rails_async_barrier = Async::Barrier.new yield + check_for_unresolved_async_components ensure + @react_on_rails_async_barrier&.stop @react_on_rails_async_barrier = nil end end + + def check_for_unresolved_async_components + return if @react_on_rails_async_barrier.nil? + + pending_tasks = @react_on_rails_async_barrier.size + return if pending_tasks.zero? + + Rails.logger.error( + "[React on Rails Pro] #{pending_tasks} async component(s) were started but never resolved. " \ + "Make sure to call .value on all AsyncValue objects returned by async_react_component " \ + "or cached_async_react_component. Unresolved tasks will be stopped." + ) + end end end 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..740d7f0dd9 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 @@ -3,6 +3,9 @@ class PagesController < ApplicationController # rubocop:disable Metrics/ClassLength include ReactOnRailsPro::RSCPayloadRenderer include RscPostsPageOverRedisHelper + include ReactOnRailsPro::AsyncRendering + + enable_async_react_rendering only: [:async_components_demo] XSS_PAYLOAD = { "" => '' }.freeze PROPS_NAME = "Mr. Server Side Rendering" @@ -157,6 +160,12 @@ def console_logs_in_async_server render "/pages/pro/console_logs_in_async_server" end + # Demo page showing 10 async components rendering concurrently + # Each component delays 1 second - sequential would take ~10s, concurrent takes ~1s + def async_components_demo + render "/pages/pro/async_components_demo" + end + # See files in spec/dummy/app/views/pages helper_method :calc_slow_app_props_server_render diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb new file mode 100644 index 0000000000..17f1355a43 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb @@ -0,0 +1,34 @@ +

Async React Components Demo

+

+ This page renders 10 React components, each with a 1-second delay. +
+ Sequential rendering: ~10 seconds +
+ Concurrent rendering (async_react_component): ~1 second +

+ +<% start_time = Time.now %> + +<% + # Start all 10 async renders immediately (non-blocking) + components = 10.times.map do |i| + async_react_component( + "DelayedComponent", + props: { index: i + 1, delayMs: 1000 }, + prerender: true + ) + end +%> + +
+ <% components.each do |component| %> + <%= component.value %> + <% end %> +
+ +<% elapsed = Time.now - start_time %> +

+ Total render time: <%= (elapsed * 1000).round %>ms +
+ If this is close to 1 second instead of 10 seconds, concurrent rendering is working! +

diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx new file mode 100644 index 0000000000..ae1d88bc41 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.client.jsx @@ -0,0 +1,12 @@ +'use client'; + +import React from 'react'; + +// Client-side version of DelayedComponent (no delay needed on client) +const DelayedComponent = ({ index, delayMs = 1000 }) => ( +
+ Component {index} - Rendered after {delayMs}ms delay +
+); + +export default DelayedComponent; diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx new file mode 100644 index 0000000000..ae8d1b5549 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx @@ -0,0 +1,21 @@ +'use client'; + +import React from 'react'; + +// Component that simulates a slow render by delaying for 1 second +// Used to demonstrate concurrent rendering with async_react_component +const DelayedComponent = ({ index, delayMs = 1000 }) => ( +
+ Component {index} - Rendered after {delayMs}ms delay +
+); + +// Async render function that delays for specified time before returning +export default async (props, _railsContext) => { + const { delayMs = 1000 } = props; + + // Simulate slow server-side data fetching + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + return () => ; +}; diff --git a/react_on_rails_pro/spec/dummy/config/routes.rb b/react_on_rails_pro/spec/dummy/config/routes.rb index 1d2f2b4e0d..3fc717ba79 100644 --- a/react_on_rails_pro/spec/dummy/config/routes.rb +++ b/react_on_rails_pro/spec/dummy/config/routes.rb @@ -38,6 +38,7 @@ get "server_router_client_render/(*all)" => "pages#server_router_client_render", as: :server_router_client_render get "async_render_function_returns_string" => "pages#async_render_function_returns_string" get "async_render_function_returns_component" => "pages#async_render_function_returns_component" + get "async_components_demo" => "pages#async_components_demo", as: :async_components_demo rsc_payload_route controller: "pages" # routes copied over from react on rails From d03434d9144380e6fa8070b676767faf31189ef5 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 27 Nov 2025 17:22:20 +0200 Subject: [PATCH 3/5] Add RBS type signatures and CHANGELOG entry for async component helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RBS signatures for AsyncValue, ImmediateAsyncValue, and AsyncRendering - Add CHANGELOG entry for the async_react_component feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- react_on_rails_pro/CHANGELOG.md | 1 + .../sig/react_on_rails_pro/async_value.rbs | 18 ++++++++++++++++++ .../concerns/async_rendering.rbs | 15 +++++++++++++++ .../immediate_async_value.rbs | 15 +++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs create mode 100644 react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs create mode 100644 react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs diff --git a/react_on_rails_pro/CHANGELOG.md b/react_on_rails_pro/CHANGELOG.md index d12b865393..965e77c7dd 100644 --- a/react_on_rails_pro/CHANGELOG.md +++ b/react_on_rails_pro/CHANGELOG.md @@ -25,6 +25,7 @@ _Add changes in master not yet tagged._ ### Added +- **Async React Component Rendering**: Added `async_react_component` and `cached_async_react_component` helpers for concurrent component rendering. Multiple components now execute HTTP requests to the Node renderer in parallel instead of sequentially, significantly reducing latency when rendering multiple components in a view. Requires `ReactOnRailsPro::AsyncRendering` concern in controller. [PR 2139](https://github.com/shakacode/react_on_rails/pull/2139) by [AbanoubGhadban](https://github.com/AbanoubGhadban). - 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 `cached_stream_react_component` helper method, similar to `cached_react_component` but for streamed components. - **License Validation System**: Implemented comprehensive JWT-based license validation with offline verification using RSA-256 signatures. License validation occurs at startup in both Ruby and Node.js environments. Supports required fields (`sub`, `iat`, `exp`) and optional fields (`plan`, `organization`, `iss`). FREE evaluation licenses are available for 3 months at [shakacode.com/react-on-rails-pro](https://shakacode.com/react-on-rails-pro). [PR #1857](https://github.com/shakacode/react_on_rails/pull/1857) by [AbanoubGhadban](https://github.com/AbanoubGhadban). diff --git a/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs new file mode 100644 index 0000000000..3178b9a7de --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs @@ -0,0 +1,18 @@ +module ReactOnRailsPro + class AsyncValue + attr_reader component_name: String + + @task: untyped + @component_name: String + + def initialize: (component_name: String, task: untyped) -> void + + def value: () -> untyped + + def resolved?: () -> bool + + def to_s: () -> String + + def html_safe: () -> untyped + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs b/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs new file mode 100644 index 0000000000..bb64e7df43 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs @@ -0,0 +1,15 @@ +module ReactOnRailsPro + module AsyncRendering + module ClassMethods + def enable_async_react_rendering: (**untyped options) -> void + end + + @react_on_rails_async_barrier: untyped + + private + + def wrap_in_async_react_context: () { () -> untyped } -> untyped + + def check_for_unresolved_async_components: () -> void + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs b/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs new file mode 100644 index 0000000000..d8ae3b4ca4 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs @@ -0,0 +1,15 @@ +module ReactOnRailsPro + class ImmediateAsyncValue + attr_reader value: untyped + + @value: untyped + + def initialize: (untyped value) -> void + + def resolved?: () -> bool + + def to_s: () -> String + + def html_safe: () -> untyped + end +end From 38d0dfd5f05eeaecc02c79b20ac12330b3137d41 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 27 Nov 2025 17:41:38 +0200 Subject: [PATCH 4/5] Fix ESLint no-promise-executor-return error in DelayedComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use block statement in Promise executor to avoid implicit return of setTimeout's timer ID. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/ror-auto-load-components/DelayedComponent.server.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx index ae8d1b5549..5dff0210b6 100644 --- a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx @@ -15,7 +15,9 @@ export default async (props, _railsContext) => { const { delayMs = 1000 } = props; // Simulate slow server-side data fetching - await new Promise((resolve) => setTimeout(resolve, delayMs)); + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); return () => ; }; From a3baa6c1a20bf5fe7d046edd14d1c792fd9eb6ea Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Fri, 28 Nov 2025 13:11:52 +0200 Subject: [PATCH 5/5] Remove component_name from AsyncValue class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component_name attribute was unused and unnecessary. This change simplifies AsyncValue to only store the task, matching the interface of ImmediateAsyncValue for consistency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/helpers/react_on_rails_pro_helper.rb | 6 +++--- .../lib/react_on_rails_pro/async_value.rb | 5 +---- .../sig/react_on_rails_pro/async_value.rbs | 5 +---- .../react_on_rails_pro/async_value_spec.rb | 21 ++++++------------- 4 files changed, 11 insertions(+), 26 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 1c38cf8664..741941c995 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 @@ -245,7 +245,7 @@ def async_react_component(component_name, options = {}) react_component(component_name, options) end - ReactOnRailsPro::AsyncValue.new(component_name: component_name, task: task) + ReactOnRailsPro::AsyncValue.new(task: task) end # Renders a React component asynchronously with caching support. @@ -397,7 +397,7 @@ def render_async_react_component_uncached(component_name, raw_options, &block) react_component(component_name, options) end - ReactOnRailsPro::AsyncValue.new(component_name: component_name, task: task) + ReactOnRailsPro::AsyncValue.new(task: task) end # Renders async and writes to cache on completion @@ -410,7 +410,7 @@ def render_async_react_component_with_cache(component_name, raw_options, cache_k result end - ReactOnRailsPro::AsyncValue.new(component_name: component_name, task: task) + ReactOnRailsPro::AsyncValue.new(task: task) end def prepare_async_render_options(raw_options) diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb index 882b4d9694..2395b17749 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb @@ -10,10 +10,7 @@ module ReactOnRailsPro # html = async_value.value # blocks until result is ready # class AsyncValue - attr_reader :component_name - - def initialize(component_name:, task:) - @component_name = component_name + def initialize(task:) @task = task end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs index 3178b9a7de..d78583c1f7 100644 --- a/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs +++ b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs @@ -1,11 +1,8 @@ module ReactOnRailsPro class AsyncValue - attr_reader component_name: String - @task: untyped - @component_name: String - def initialize: (component_name: String, task: untyped) -> void + def initialize: (task: untyped) -> void def value: () -> untyped diff --git a/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb index 9fc14f44aa..b041c6296c 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/async_value_spec.rb @@ -6,15 +6,6 @@ module ReactOnRailsPro RSpec.describe AsyncValue do - describe "#initialize" do - it "stores the component name and task" do - task = instance_double(Async::Task) - async_value = described_class.new(component_name: "MyComponent", task: task) - - expect(async_value.component_name).to eq("MyComponent") - end - end - describe "#value" do it "returns the task result when task completes successfully" do Sync do @@ -22,7 +13,7 @@ module ReactOnRailsPro "
Hello
" end - async_value = described_class.new(component_name: "MyComponent", task: task) + async_value = described_class.new(task: task) expect(async_value.value).to eq("
Hello
") end end @@ -33,7 +24,7 @@ module ReactOnRailsPro raise StandardError, "Render failed" end - async_value = described_class.new(component_name: "MyComponent", task: task) + async_value = described_class.new(task: task) expect { async_value.value }.to raise_error(StandardError, "Render failed") end end @@ -49,7 +40,7 @@ module ReactOnRailsPro "result" end - async_value = described_class.new(component_name: "MyComponent", task: task) + async_value = described_class.new(task: task) expect(async_value.resolved?).to be false barrier.wait @@ -63,7 +54,7 @@ module ReactOnRailsPro end task.wait - async_value = described_class.new(component_name: "MyComponent", task: task) + async_value = described_class.new(task: task) expect(async_value.resolved?).to be true end end @@ -76,7 +67,7 @@ module ReactOnRailsPro "
Content
" end - async_value = described_class.new(component_name: "MyComponent", task: task) + async_value = described_class.new(task: task) expect(async_value.to_s).to eq("
Content
") end end @@ -89,7 +80,7 @@ module ReactOnRailsPro "
Content
" end - async_value = described_class.new(component_name: "MyComponent", task: task) + async_value = described_class.new(task: task) result = async_value.html_safe expect(result).to be_html_safe