Skip to content

Commit 5d78a83

Browse files
Add async_react_component and cached_async_react_component helpers (#2138)
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 <[email protected]>
1 parent 16b3908 commit 5d78a83

File tree

8 files changed

+567
-0
lines changed

8 files changed

+567
-0
lines changed

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(component_name: component_name, 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(component_name: component_name, 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(component_name: component_name, 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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
attr_reader :component_name
14+
15+
def initialize(component_name:, task:)
16+
@component_name = component_name
17+
@task = task
18+
end
19+
20+
# Blocks until result is ready, returns HTML string.
21+
# If the async task raised an exception, it will be re-raised here.
22+
def value
23+
@task.wait
24+
end
25+
26+
def resolved?
27+
@task.finished?
28+
end
29+
30+
def to_s
31+
value.to_s
32+
end
33+
34+
def html_safe
35+
value.html_safe
36+
end
37+
end
38+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
ensure
49+
@react_on_rails_async_barrier = nil
50+
end
51+
end
52+
end
53+
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

0 commit comments

Comments
 (0)