diff --git a/react_on_rails_pro/CHANGELOG.md b/react_on_rails_pro/CHANGELOG.md index d12b865393..965e77c7dd 100644 --- a/react_on_rails_pro/CHANGELOG.md +++ b/react_on_rails_pro/CHANGELOG.md @@ -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). diff --git a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb index 69c20af010..741941c995 100644 --- a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb +++ b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb @@ -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(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" @@ -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(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(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" diff --git a/react_on_rails_pro/lib/react_on_rails_pro.rb b/react_on_rails_pro/lib/react_on_rails_pro.rb index 5dbe2dafae..b7695b028d 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro.rb @@ -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" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb new file mode 100644 index 0000000000..2395b17749 --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/async_value.rb @@ -0,0 +1,35 @@ +# 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 + def initialize(task:) + @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 diff --git a/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb new file mode 100644 index 0000000000..ee5de8649f --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/concerns/async_rendering.rb @@ -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 diff --git a/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb b/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb new file mode 100644 index 0000000000..1282b105bf --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/immediate_async_value.rb @@ -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 diff --git a/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs new file mode 100644 index 0000000000..d78583c1f7 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/async_value.rbs @@ -0,0 +1,15 @@ +module ReactOnRailsPro + class AsyncValue + @task: untyped + + def initialize: (task: untyped) -> void + + def value: () -> untyped + + def resolved?: () -> bool + + def to_s: () -> String + + def html_safe: () -> untyped + end +end diff --git a/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs b/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs new file mode 100644 index 0000000000..bb64e7df43 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/concerns/async_rendering.rbs @@ -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 diff --git a/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs b/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs new file mode 100644 index 0000000000..d8ae3b4ca4 --- /dev/null +++ b/react_on_rails_pro/sig/react_on_rails_pro/immediate_async_value.rbs @@ -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 diff --git a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb index 7a33950f91..740d7f0dd9 100644 --- a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb +++ b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb @@ -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 = { "" => '' }.freeze PROPS_NAME = "Mr. Server Side Rendering" @@ -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 diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb new file mode 100644 index 0000000000..17f1355a43 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/app/views/pages/pro/async_components_demo.html.erb @@ -0,0 +1,34 @@ +
+ This page renders 10 React components, each with a 1-second delay.
+
+ Sequential rendering: ~10 seconds
+
+ Concurrent rendering (async_react_component): ~1 second
+
+ Total render time: <%= (elapsed * 1000).round %>ms
+
+ If this is close to 1 second instead of 10 seconds, concurrent rendering is working!
+