Skip to content

Commit f7d948d

Browse files
authored
Add cached_stream_react_component helper (#549)
Follow-up to #548 ### What - Introduces `cached_stream_react_component`, mirroring `cached_react_component`: - Requires `cache_key` and `props` block (lazy props on HIT) - Honors `:if` / `:unless` and `:cache_options` (TTL/compression) - Implements view-level streaming cache by decorating the fiber produced by `stream_react_component`: - **MISS**: call `stream_react_component` once, wrap its fiber to buffer chunks and write-through to `Rails.cache`; return initial chunk immediately - **HIT**: read cached chunks; return initial chunk and push a replay fiber for the remainder - Preserves streaming semantics/formatting; no behavior change unless the helper is used - Adds/updates helper spec coverage to exercise MISS→HIT, skip, invalidation, and TTL ### Why - Prerender caching keys by `js_code` digest (needs serialized props), so it can’t lazily skip props - View-level cache enables app-defined keys and true lazy props evaluation on HIT, matching the non-stream helper’s ergonomics <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Adds cached streaming for server-rendered React components to speed repeat requests while preserving prerender/hydration behavior, honoring existing cache controls, conditional caching, and instrumentation. * Adds a demo/test page and route for the cached streaming variant. * **Tests** * Expanded unit and system tests covering cache miss→hit flow, write-through caching, invalidation on prop changes, skip-prerender-cache, that props are not re-evaluated on hit, streaming chunk ordering, and deterministic mocked streaming. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4e51b46 commit f7d948d

File tree

5 files changed

+329
-7
lines changed

5 files changed

+329
-7
lines changed

app/helpers/react_on_rails_pro_helper.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,99 @@ def cached_react_component_hash(component_name, raw_options = {}, &block)
9191
end
9292
end
9393

94+
# Provide caching support for stream_react_component in a manner akin to Rails fragment caching.
95+
# All the same options as stream_react_component apply with the following differences:
96+
#
97+
# 1. You must pass the props as a block. This is so that the evaluation of the props is not done
98+
# if the cache can be used.
99+
# 2. Provide the cache_key option
100+
# cache_key: String or Array (or Proc returning a String or Array) containing your cache keys.
101+
# Since prerender is automatically set to true, the server bundle digest will be included in the cache key.
102+
# The cache_key value is the same as used for conventional Rails fragment caching.
103+
# 3. Optionally provide the `:cache_options` key with a value of a hash including as
104+
# :compress, :expires_in, :race_condition_ttl as documented in the Rails Guides
105+
# 4. Provide boolean values for `:if` or `:unless` to conditionally use caching.
106+
def cached_stream_react_component(component_name, raw_options = {}, &block)
107+
ReactOnRailsPro::Utils.with_trace(component_name) do
108+
check_caching_options!(raw_options, block)
109+
fetch_stream_react_component(component_name, raw_options, &block)
110+
end
111+
end
112+
94113
if defined?(ScoutApm)
95114
include ScoutApm::Tracer
96115
instrument_method :cached_react_component, type: "ReactOnRails", name: "cached_react_component"
97116
instrument_method :cached_react_component_hash, type: "ReactOnRails", name: "cached_react_component_hash"
117+
instrument_method :cached_stream_react_component, type: "ReactOnRails", name: "cached_stream_react_component"
98118
end
99119

100120
private
101121

122+
def fetch_stream_react_component(component_name, raw_options, &block)
123+
auto_load_bundle = ReactOnRails.configuration.auto_load_bundle || raw_options[:auto_load_bundle]
124+
125+
unless ReactOnRailsPro::Cache.use_cache?(raw_options)
126+
return render_stream_component_with_props(component_name, raw_options, auto_load_bundle, &block)
127+
end
128+
129+
# Compose a cache key consistent with non-stream helper semantics.
130+
key_options = raw_options.merge(prerender: true)
131+
view_cache_key = ReactOnRailsPro::Cache.react_component_cache_key(component_name, key_options)
132+
133+
# Attempt HIT without evaluating props block
134+
if (cached_chunks = Rails.cache.read(view_cache_key)).is_a?(Array)
135+
return handle_stream_cache_hit(component_name, raw_options, auto_load_bundle, cached_chunks)
136+
end
137+
138+
# MISS: evaluate props lazily, stream live, and write-through to view-level cache
139+
handle_stream_cache_miss(component_name, raw_options, auto_load_bundle, view_cache_key, &block)
140+
end
141+
142+
def handle_stream_cache_hit(component_name, raw_options, auto_load_bundle, cached_chunks)
143+
render_options = ReactOnRails::ReactComponent::RenderOptions.new(
144+
react_component_name: component_name,
145+
options: { auto_load_bundle: auto_load_bundle }.merge(raw_options)
146+
)
147+
load_pack_for_generated_component(component_name, render_options)
148+
149+
initial_result, *rest_chunks = cached_chunks
150+
hit_fiber = Fiber.new do
151+
rest_chunks.each { |chunk| Fiber.yield(chunk) }
152+
nil
153+
end
154+
@rorp_rendering_fibers << hit_fiber
155+
initial_result
156+
end
157+
158+
def handle_stream_cache_miss(component_name, raw_options, auto_load_bundle, view_cache_key, &block)
159+
# Kick off the normal streaming helper to get the initial result and the original fiber
160+
initial_result = render_stream_component_with_props(component_name, raw_options, auto_load_bundle, &block)
161+
original_fiber = @rorp_rendering_fibers.pop
162+
163+
buffered_chunks = [initial_result]
164+
wrapper_fiber = Fiber.new do
165+
while (chunk = original_fiber.resume)
166+
buffered_chunks << chunk
167+
Fiber.yield(chunk)
168+
end
169+
Rails.cache.write(view_cache_key, buffered_chunks, raw_options[:cache_options] || {})
170+
nil
171+
end
172+
@rorp_rendering_fibers << wrapper_fiber
173+
initial_result
174+
end
175+
176+
def render_stream_component_with_props(component_name, raw_options, auto_load_bundle)
177+
props = yield
178+
options = raw_options.merge(
179+
props: props,
180+
prerender: true,
181+
skip_prerender_cache: true,
182+
auto_load_bundle: auto_load_bundle
183+
)
184+
stream_react_component(component_name, options)
185+
end
186+
102187
def check_caching_options!(raw_options, block)
103188
raise ReactOnRailsPro::Error, "Pass 'props' as a block if using caching" if raw_options.key?(:props) || block.nil?
104189

spec/dummy/app/controllers/pages_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def stream_async_components_for_testing
4141
stream_view_containing_react_components(template: "/pages/stream_async_components_for_testing")
4242
end
4343

44+
def cached_stream_async_components_for_testing
45+
stream_view_containing_react_components(template: "/pages/cached_stream_async_components_for_testing")
46+
end
47+
4448
def rsc_posts_page_over_http
4549
stream_view_containing_react_components(template: "/pages/rsc_posts_page_over_http")
4650
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<%# Cached variant of the test page for streaming components %>
2+
<%
3+
throw_sync = params[:throwSyncError]
4+
throw_async = params[:throwAsyncError]
5+
%>
6+
<%= cached_stream_react_component(
7+
"AsyncComponentsTreeForTesting",
8+
cache_key: ["AsyncComponentsTreeForTesting", throw_sync, throw_async],
9+
trace: true,
10+
id: "AsyncComponentsTreeForTesting-react-component-0",
11+
) do
12+
@app_props_server_render.merge(params.permit(:throwSyncError, :throwAsyncError))
13+
end %>
14+
<hr/>
15+
16+
<h1>React Rails Server Streaming Server Rendered Async React Components Tree For Testing (Cached)</h1>
17+
18+

spec/dummy/config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
get "stream_async_components" => "pages#stream_async_components", as: :stream_async_components
2424
get "stream_async_components_for_testing" => "pages#stream_async_components_for_testing",
2525
as: :stream_async_components_for_testing
26+
get "cached_stream_async_components_for_testing" => "pages#cached_stream_async_components_for_testing",
27+
as: :cached_stream_async_components_for_testing
2628
get "stream_async_components_for_testing_client_render" => "pages#stream_async_components_for_testing_client_render",
2729
as: :stream_async_components_for_testing_client_render
2830
get "rsc_posts_page_over_http" => "pages#rsc_posts_page_over_http", as: :rsc_posts_page_over_http

0 commit comments

Comments
 (0)