Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
123 changes: 123 additions & 0 deletions react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions react_on_rails_pro/lib/react_on_rails_pro.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
38 changes: 38 additions & 0 deletions react_on_rails_pro/lib/react_on_rails_pro/async_value.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading