Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions react_on_rails_pro/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
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
18 changes: 18 additions & 0 deletions react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
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>
Loading
Loading