Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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,68 @@
# 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
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 = { "<script>window.alert('xss1');</script>" => '<script>window.alert("xss2");</script>' }.freeze
PROPS_NAME = "Mr. Server Side Rendering"
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<h1>Async React Components Demo</h1>
<p>
This page renders 10 React components, each with a 1-second delay.
<br>
<strong>Sequential rendering:</strong> ~10 seconds
<br>
<strong>Concurrent rendering (async_react_component):</strong> ~1 second
</p>

<% 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
%>

<div id="components-container">
<% components.each do |component| %>
<%= component.value %>
<% end %>
</div>

<% elapsed = Time.now - start_time %>
<p>
<strong>Total render time:</strong> <%= (elapsed * 1000).round %>ms
<br>
<em>If this is close to 1 second instead of 10 seconds, concurrent rendering is working!</em>
</p>
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div style={{ padding: '10px', margin: '5px', border: '1px solid #ccc' }}>
<strong>Component {index}</strong> - Rendered after {delayMs}ms delay
</div>
);

export default DelayedComponent;
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div style={{ padding: '10px', margin: '5px', border: '1px solid #ccc' }}>
<strong>Component {index}</strong> - Rendered after {delayMs}ms delay
</div>
);
Comment on lines +1 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove 'use client' directive from server component file.

The file is named DelayedComponent.server.jsx, which indicates a server component, but it contains a 'use client' directive (Line 1). These are contradictory:

  • Server components (.server.jsx) run only on the server
  • The 'use client' directive marks a component boundary for client-side rendering

Based on the async function export pattern (Lines 14-21) and the server-side delay simulation, this should be a server component.

Apply this diff to remove the directive:

-'use client';
-
 import React from 'react';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'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 }) => (
<div style={{ padding: '10px', margin: '5px', border: '1px solid #ccc' }}>
<strong>Component {index}</strong> - Rendered after {delayMs}ms delay
</div>
);
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 }) => (
<div style={{ padding: '10px', margin: '5px', border: '1px solid #ccc' }}>
<strong>Component {index}</strong> - Rendered after {delayMs}ms delay
</div>
);
🧰 Tools
🪛 GitHub Actions: React on Rails Pro - Lint

[error] 1-1: ESLint: 'no-promise-executor-return' violation. Return values from promise executor functions cannot be read. (no-promise-executor-return)

🤖 Prompt for AI Agents
In
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx
lines 1-11, remove the top `'use client'` directive because this file is a
server component (.server.jsx) and the directive is contradictory; delete the
first line, verify no client-only hooks or browser APIs are used later in the
file, keep the React import and the server-side async component/export as-is,
and run tests/build to ensure no client-only code remains.


// 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));

Check failure on line 18 in react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/DelayedComponent.server.jsx

View workflow job for this annotation

GitHub Actions / pro-lint-js-and-ruby

Return values from promise executor functions cannot be read

return () => <DelayedComponent {...props} />;
};
1 change: 1 addition & 0 deletions react_on_rails_pro/spec/dummy/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading