Skip to content

Commit 35c477b

Browse files
Add async_react_component and cached_async_react_component helpers (#2139)
## Summary Adds concurrent React component rendering support for React on Rails Pro. Closes #2138. - **`async_react_component`**: Returns `AsyncValue` immediately, renders components concurrently - **`cached_async_react_component`**: Async rendering with fragment caching support (same options as `cached_react_component`) - **`AsyncRendering` concern**: Controller mixin with `enable_async_react_rendering` class method ## Motivation Currently, multiple `react_component` calls in a Rails view run sequentially. Each call makes an HTTP request to the Node renderer, causing delays proportional to the number of components. This PR enables concurrent execution using the `async` gem (already used in the codebase for streaming). ## Usage ### Controller Setup ```ruby class ProductsController < ApplicationController include ReactOnRailsPro::AsyncRendering enable_async_react_rendering only: [:show, :index] end ``` ### View Usage ```erb <%# Start async renders (non-blocking) %> <% header = async_react_component("Header", props: @header_props) %> <% sidebar = async_react_component("Sidebar", props: @sidebar_props) %> <% content = async_react_component("Content", props: @content_props) %> <%# Access values (blocks until ready) %> <%= header.value %> <%= sidebar.value %> <%= content.value %> ``` ### With Caching ```erb <% card = cached_async_react_component("ProductCard", cache_key: @Product) { @product.to_props } %> <%= card.value %> ``` ## Implementation Details - Uses `Async::Barrier` for coordinating concurrent tasks within a request - Cache lookup is synchronous - cache hits return `ImmediateAsyncValue` (no async overhead) - Cache misses trigger async render and write to cache on completion - Errors propagate naturally via `task.wait` - Supports all `cached_react_component` options: `:cache_key`, `:cache_options`, `:if`, `:unless` ## Test plan - [x] Unit tests for `AsyncValue` class - [x] Unit tests for `ImmediateAsyncValue` class - [x] Integration tests for `async_react_component` - [x] Integration tests for `cached_async_react_component` - [x] Concurrent execution verification test - [x] Error propagation tests - [x] Cache hit/miss behavior tests - [x] Conditional caching (`:if`/`:unless`) tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Adds asynchronous server-side rendering for React components with barrier-backed concurrency and optional caching; cache hits return synchronously, misses queue async renders. * Controller opt-in to enable async rendering for selected actions. * Demo page and sample delayed components to illustrate concurrent rendering and timing. * **Tests** * Extensive tests covering async rendering, caching behavior, error propagation, and async value semantics. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude <[email protected]>
1 parent 48ee87b commit 35c477b

File tree

17 files changed

+695
-0
lines changed

17 files changed

+695
-0
lines changed

react_on_rails_pro/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ _Add changes in master not yet tagged._
2525

2626
### Added
2727

28+
- **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).
2829
- 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.
2930
- Added `cached_stream_react_component` helper method, similar to `cached_react_component` but for streamed components.
3031
- **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).

react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,63 @@ def cached_stream_react_component(component_name, raw_options = {}, &block)
217217
end
218218
end
219219

220+
# Renders a React component asynchronously, returning an AsyncValue immediately.
221+
# Multiple async_react_component calls will execute their HTTP rendering requests
222+
# concurrently instead of sequentially.
223+
#
224+
# Requires the controller to include ReactOnRailsPro::AsyncRendering and call
225+
# enable_async_react_rendering.
226+
#
227+
# @param component_name [String] Name of your registered component
228+
# @param options [Hash] Same options as react_component
229+
# @return [ReactOnRailsPro::AsyncValue] Call .value to get the rendered HTML
230+
#
231+
# @example
232+
# <% header = async_react_component("Header", props: @header_props) %>
233+
# <% sidebar = async_react_component("Sidebar", props: @sidebar_props) %>
234+
# <%= header.value %>
235+
# <%= sidebar.value %>
236+
#
237+
def async_react_component(component_name, options = {})
238+
unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier
239+
raise ReactOnRailsPro::Error,
240+
"async_react_component requires AsyncRendering concern. " \
241+
"Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering."
242+
end
243+
244+
task = @react_on_rails_async_barrier.async do
245+
react_component(component_name, options)
246+
end
247+
248+
ReactOnRailsPro::AsyncValue.new(task: task)
249+
end
250+
251+
# Renders a React component asynchronously with caching support.
252+
# Cache lookup is synchronous - cache hits return immediately without async.
253+
# Cache misses trigger async render and cache the result on completion.
254+
#
255+
# All the same options as cached_react_component apply:
256+
# 1. You must pass the props as a block (evaluated only on cache miss)
257+
# 2. Provide the cache_key option
258+
# 3. Optionally provide :cache_options for Rails.cache (expires_in, etc.)
259+
# 4. Provide :if or :unless for conditional caching
260+
#
261+
# @param component_name [String] Name of your registered component
262+
# @param options [Hash] Options including cache_key and cache_options
263+
# @yield Block that returns props (evaluated only on cache miss)
264+
# @return [ReactOnRailsPro::AsyncValue, ReactOnRailsPro::ImmediateAsyncValue]
265+
#
266+
# @example
267+
# <% card = cached_async_react_component("ProductCard", cache_key: @product) { @product.to_props } %>
268+
# <%= card.value %>
269+
#
270+
def cached_async_react_component(component_name, raw_options = {}, &block)
271+
ReactOnRailsPro::Utils.with_trace(component_name) do
272+
check_caching_options!(raw_options, block)
273+
fetch_async_react_component(component_name, raw_options, &block)
274+
end
275+
end
276+
220277
if defined?(ScoutApm)
221278
include ScoutApm::Tracer
222279
instrument_method :cached_react_component, type: "ReactOnRails", name: "cached_react_component"
@@ -298,6 +355,72 @@ def check_caching_options!(raw_options, block)
298355
raise ReactOnRailsPro::Error, "Option 'cache_key' is required for React on Rails caching"
299356
end
300357

358+
# Async version of fetch_react_component. Handles cache lookup synchronously,
359+
# returns ImmediateAsyncValue on hit, AsyncValue on miss.
360+
def fetch_async_react_component(component_name, raw_options, &block)
361+
unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier
362+
raise ReactOnRailsPro::Error,
363+
"cached_async_react_component requires AsyncRendering concern. " \
364+
"Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering."
365+
end
366+
367+
# Check conditional caching (:if / :unless options)
368+
unless ReactOnRailsPro::Cache.use_cache?(raw_options)
369+
return render_async_react_component_uncached(component_name, raw_options, &block)
370+
end
371+
372+
cache_key = ReactOnRailsPro::Cache.react_component_cache_key(component_name, raw_options)
373+
cache_options = raw_options[:cache_options] || {}
374+
Rails.logger.debug { "React on Rails Pro async cache_key is #{cache_key.inspect}" }
375+
376+
# Synchronous cache lookup
377+
cached_result = Rails.cache.read(cache_key, cache_options)
378+
if cached_result
379+
Rails.logger.debug { "React on Rails Pro async cache HIT for #{cache_key.inspect}" }
380+
render_options = ReactOnRails::ReactComponent::RenderOptions.new(
381+
react_component_name: component_name,
382+
options: raw_options
383+
)
384+
load_pack_for_generated_component(component_name, render_options)
385+
return ReactOnRailsPro::ImmediateAsyncValue.new(cached_result)
386+
end
387+
388+
Rails.logger.debug { "React on Rails Pro async cache MISS for #{cache_key.inspect}" }
389+
render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block)
390+
end
391+
392+
# Renders async without caching (when :if/:unless conditions disable cache)
393+
def render_async_react_component_uncached(component_name, raw_options, &block)
394+
options = prepare_async_render_options(raw_options, &block)
395+
396+
task = @react_on_rails_async_barrier.async do
397+
react_component(component_name, options)
398+
end
399+
400+
ReactOnRailsPro::AsyncValue.new(task: task)
401+
end
402+
403+
# Renders async and writes to cache on completion
404+
def render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block)
405+
options = prepare_async_render_options(raw_options, &block)
406+
407+
task = @react_on_rails_async_barrier.async do
408+
result = react_component(component_name, options)
409+
Rails.cache.write(cache_key, result, cache_options)
410+
result
411+
end
412+
413+
ReactOnRailsPro::AsyncValue.new(task: task)
414+
end
415+
416+
def prepare_async_render_options(raw_options)
417+
raw_options.merge(
418+
props: yield,
419+
skip_prerender_cache: true,
420+
auto_load_bundle: ReactOnRails.configuration.auto_load_bundle || raw_options[:auto_load_bundle]
421+
)
422+
end
423+
301424
def consumer_stream_async(on_complete:)
302425
require "async/variable"
303426

react_on_rails_pro/lib/react_on_rails_pro.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@
2020
require "react_on_rails_pro/prepare_node_renderer_bundles"
2121
require "react_on_rails_pro/concerns/stream"
2222
require "react_on_rails_pro/concerns/rsc_payload_renderer"
23+
require "react_on_rails_pro/concerns/async_rendering"
24+
require "react_on_rails_pro/async_value"
25+
require "react_on_rails_pro/immediate_async_value"
2326
require "react_on_rails_pro/routes"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
module ReactOnRailsPro
4+
# AsyncValue wraps an Async task to provide a simple interface for
5+
# retrieving the result of an async react_component render.
6+
#
7+
# @example
8+
# async_value = async_react_component("MyComponent", props: { name: "World" })
9+
# # ... do other work ...
10+
# html = async_value.value # blocks until result is ready
11+
#
12+
class AsyncValue
13+
def initialize(task:)
14+
@task = task
15+
end
16+
17+
# Blocks until result is ready, returns HTML string.
18+
# If the async task raised an exception, it will be re-raised here.
19+
def value
20+
@task.wait
21+
end
22+
23+
def resolved?
24+
@task.finished?
25+
end
26+
27+
def to_s
28+
value.to_s
29+
end
30+
31+
def html_safe
32+
value.html_safe
33+
end
34+
end
35+
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
module ReactOnRailsPro
4+
# AsyncRendering enables concurrent rendering of multiple React components.
5+
# When enabled, async_react_component calls will execute their HTTP requests
6+
# in parallel instead of sequentially.
7+
#
8+
# @example Enable for all actions
9+
# class ProductsController < ApplicationController
10+
# include ReactOnRailsPro::AsyncRendering
11+
# enable_async_react_rendering
12+
# end
13+
#
14+
# @example Enable for specific actions only
15+
# class ProductsController < ApplicationController
16+
# include ReactOnRailsPro::AsyncRendering
17+
# enable_async_react_rendering only: [:show, :index]
18+
# end
19+
#
20+
# @example Enable for all except specific actions
21+
# class ProductsController < ApplicationController
22+
# include ReactOnRailsPro::AsyncRendering
23+
# enable_async_react_rendering except: [:create, :update]
24+
# end
25+
#
26+
module AsyncRendering
27+
extend ActiveSupport::Concern
28+
29+
class_methods do
30+
# Enables async React component rendering for controller actions.
31+
# Accepts standard Rails filter options like :only and :except.
32+
#
33+
# @param options [Hash] Options passed to around_action (e.g., only:, except:)
34+
def enable_async_react_rendering(**options)
35+
around_action :wrap_in_async_react_context, **options
36+
end
37+
end
38+
39+
private
40+
41+
def wrap_in_async_react_context
42+
require "async"
43+
require "async/barrier"
44+
45+
Sync do
46+
@react_on_rails_async_barrier = Async::Barrier.new
47+
yield
48+
check_for_unresolved_async_components
49+
ensure
50+
@react_on_rails_async_barrier&.stop
51+
@react_on_rails_async_barrier = nil
52+
end
53+
end
54+
55+
def check_for_unresolved_async_components
56+
return if @react_on_rails_async_barrier.nil?
57+
58+
pending_tasks = @react_on_rails_async_barrier.size
59+
return if pending_tasks.zero?
60+
61+
Rails.logger.error(
62+
"[React on Rails Pro] #{pending_tasks} async component(s) were started but never resolved. " \
63+
"Make sure to call .value on all AsyncValue objects returned by async_react_component " \
64+
"or cached_async_react_component. Unresolved tasks will be stopped."
65+
)
66+
end
67+
end
68+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
module ReactOnRailsPro
4+
# ImmediateAsyncValue is returned when a cached_async_react_component call
5+
# has a cache hit. It provides the same interface as AsyncValue but returns
6+
# the cached value immediately without any async operations.
7+
#
8+
class ImmediateAsyncValue
9+
def initialize(value)
10+
@value = value
11+
end
12+
13+
attr_reader :value
14+
15+
def resolved?
16+
true
17+
end
18+
19+
def to_s
20+
@value.to_s
21+
end
22+
23+
def html_safe
24+
@value.html_safe
25+
end
26+
end
27+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module ReactOnRailsPro
2+
class AsyncValue
3+
@task: untyped
4+
5+
def initialize: (task: untyped) -> void
6+
7+
def value: () -> untyped
8+
9+
def resolved?: () -> bool
10+
11+
def to_s: () -> String
12+
13+
def html_safe: () -> untyped
14+
end
15+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module ReactOnRailsPro
2+
module AsyncRendering
3+
module ClassMethods
4+
def enable_async_react_rendering: (**untyped options) -> void
5+
end
6+
7+
@react_on_rails_async_barrier: untyped
8+
9+
private
10+
11+
def wrap_in_async_react_context: () { () -> untyped } -> untyped
12+
13+
def check_for_unresolved_async_components: () -> void
14+
end
15+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module ReactOnRailsPro
2+
class ImmediateAsyncValue
3+
attr_reader value: untyped
4+
5+
@value: untyped
6+
7+
def initialize: (untyped value) -> void
8+
9+
def resolved?: () -> bool
10+
11+
def to_s: () -> String
12+
13+
def html_safe: () -> untyped
14+
end
15+
end

react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
class PagesController < ApplicationController # rubocop:disable Metrics/ClassLength
44
include ReactOnRailsPro::RSCPayloadRenderer
55
include RscPostsPageOverRedisHelper
6+
include ReactOnRailsPro::AsyncRendering
7+
8+
enable_async_react_rendering only: [:async_components_demo]
69

710
XSS_PAYLOAD = { "<script>window.alert('xss1');</script>" => '<script>window.alert("xss2");</script>' }.freeze
811
PROPS_NAME = "Mr. Server Side Rendering"
@@ -157,6 +160,12 @@ def console_logs_in_async_server
157160
render "/pages/pro/console_logs_in_async_server"
158161
end
159162

163+
# Demo page showing 10 async components rendering concurrently
164+
# Each component delays 1 second - sequential would take ~10s, concurrent takes ~1s
165+
def async_components_demo
166+
render "/pages/pro/async_components_demo"
167+
end
168+
160169
# See files in spec/dummy/app/views/pages
161170

162171
helper_method :calc_slow_app_props_server_render

0 commit comments

Comments
 (0)