react_on_rails_pro 16.2.0.beta.12 → 16.2.0.beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -1
- data/.prettierignore +0 -1
- data/CHANGELOG.md +3 -0
- data/Gemfile.lock +115 -104
- data/app/helpers/react_on_rails_pro_helper.rb +188 -32
- data/eslint.config.mjs +26 -7
- data/lib/react_on_rails_pro/async_value.rb +35 -0
- data/lib/react_on_rails_pro/concerns/async_rendering.rb +68 -0
- data/lib/react_on_rails_pro/concerns/stream.rb +62 -49
- data/lib/react_on_rails_pro/configuration.rb +20 -10
- data/lib/react_on_rails_pro/immediate_async_value.rb +27 -0
- data/lib/react_on_rails_pro/version.rb +1 -1
- data/lib/react_on_rails_pro.rb +3 -0
- data/package-scripts.yml +20 -37
- data/package.json +9 -154
- data/rakelib/run_rspec.rake +1 -1
- data/react_on_rails_pro.gemspec +9 -7
- data/sig/react_on_rails_pro/async_value.rbs +15 -0
- data/sig/react_on_rails_pro/concerns/async_rendering.rbs +15 -0
- data/sig/react_on_rails_pro/immediate_async_value.rbs +15 -0
- metadata +24 -6
- data/script/preinstall.js +0 -31
- data/yarn.lock +0 -7599
|
@@ -128,7 +128,11 @@ module ReactOnRailsProHelper
|
|
|
128
128
|
# Because setting prerender to false is equivalent to calling react_component with prerender: false
|
|
129
129
|
options[:prerender] = true
|
|
130
130
|
options = options.merge(immediate_hydration: true) unless options.key?(:immediate_hydration)
|
|
131
|
-
|
|
131
|
+
|
|
132
|
+
# Extract streaming-specific callback
|
|
133
|
+
on_complete = options.delete(:on_complete)
|
|
134
|
+
|
|
135
|
+
consumer_stream_async(on_complete: on_complete) do
|
|
132
136
|
internal_stream_react_component(component_name, options)
|
|
133
137
|
end
|
|
134
138
|
end
|
|
@@ -185,7 +189,11 @@ module ReactOnRailsProHelper
|
|
|
185
189
|
# rsc_payload_react_component doesn't have the prerender option
|
|
186
190
|
# Because setting prerender to false will not do anything
|
|
187
191
|
options[:prerender] = true
|
|
188
|
-
|
|
192
|
+
|
|
193
|
+
# Extract streaming-specific callback
|
|
194
|
+
on_complete = options.delete(:on_complete)
|
|
195
|
+
|
|
196
|
+
consumer_stream_async(on_complete: on_complete) do
|
|
189
197
|
internal_rsc_payload_react_component(component_name, options)
|
|
190
198
|
end
|
|
191
199
|
end
|
|
@@ -209,6 +217,63 @@ module ReactOnRailsProHelper
|
|
|
209
217
|
end
|
|
210
218
|
end
|
|
211
219
|
|
|
220
|
+
# Renders a React component asynchronously, returning an AsyncValue immediately.
|
|
221
|
+
# Multiple async_react_component calls will execute their HTTP rendering requests
|
|
222
|
+
# concurrently instead of sequentially.
|
|
223
|
+
#
|
|
224
|
+
# Requires the controller to include ReactOnRailsPro::AsyncRendering and call
|
|
225
|
+
# enable_async_react_rendering.
|
|
226
|
+
#
|
|
227
|
+
# @param component_name [String] Name of your registered component
|
|
228
|
+
# @param options [Hash] Same options as react_component
|
|
229
|
+
# @return [ReactOnRailsPro::AsyncValue] Call .value to get the rendered HTML
|
|
230
|
+
#
|
|
231
|
+
# @example
|
|
232
|
+
# <% header = async_react_component("Header", props: @header_props) %>
|
|
233
|
+
# <% sidebar = async_react_component("Sidebar", props: @sidebar_props) %>
|
|
234
|
+
# <%= header.value %>
|
|
235
|
+
# <%= sidebar.value %>
|
|
236
|
+
#
|
|
237
|
+
def async_react_component(component_name, options = {})
|
|
238
|
+
unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier
|
|
239
|
+
raise ReactOnRailsPro::Error,
|
|
240
|
+
"async_react_component requires AsyncRendering concern. " \
|
|
241
|
+
"Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering."
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
task = @react_on_rails_async_barrier.async do
|
|
245
|
+
react_component(component_name, options)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
ReactOnRailsPro::AsyncValue.new(task: task)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Renders a React component asynchronously with caching support.
|
|
252
|
+
# Cache lookup is synchronous - cache hits return immediately without async.
|
|
253
|
+
# Cache misses trigger async render and cache the result on completion.
|
|
254
|
+
#
|
|
255
|
+
# All the same options as cached_react_component apply:
|
|
256
|
+
# 1. You must pass the props as a block (evaluated only on cache miss)
|
|
257
|
+
# 2. Provide the cache_key option
|
|
258
|
+
# 3. Optionally provide :cache_options for Rails.cache (expires_in, etc.)
|
|
259
|
+
# 4. Provide :if or :unless for conditional caching
|
|
260
|
+
#
|
|
261
|
+
# @param component_name [String] Name of your registered component
|
|
262
|
+
# @param options [Hash] Options including cache_key and cache_options
|
|
263
|
+
# @yield Block that returns props (evaluated only on cache miss)
|
|
264
|
+
# @return [ReactOnRailsPro::AsyncValue, ReactOnRailsPro::ImmediateAsyncValue]
|
|
265
|
+
#
|
|
266
|
+
# @example
|
|
267
|
+
# <% card = cached_async_react_component("ProductCard", cache_key: @product) { @product.to_props } %>
|
|
268
|
+
# <%= card.value %>
|
|
269
|
+
#
|
|
270
|
+
def cached_async_react_component(component_name, raw_options = {}, &block)
|
|
271
|
+
ReactOnRailsPro::Utils.with_trace(component_name) do
|
|
272
|
+
check_caching_options!(raw_options, block)
|
|
273
|
+
fetch_async_react_component(component_name, raw_options, &block)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
212
277
|
if defined?(ScoutApm)
|
|
213
278
|
include ScoutApm::Tracer
|
|
214
279
|
instrument_method :cached_react_component, type: "ReactOnRails", name: "cached_react_component"
|
|
@@ -246,30 +311,29 @@ module ReactOnRailsProHelper
|
|
|
246
311
|
load_pack_for_generated_component(component_name, render_options)
|
|
247
312
|
|
|
248
313
|
initial_result, *rest_chunks = cached_chunks
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
314
|
+
|
|
315
|
+
# Enqueue remaining chunks asynchronously
|
|
316
|
+
@async_barrier.async do
|
|
317
|
+
rest_chunks.each { |chunk| @main_output_queue.enqueue(chunk) }
|
|
252
318
|
end
|
|
253
|
-
|
|
319
|
+
|
|
320
|
+
# Return first chunk directly
|
|
254
321
|
initial_result
|
|
255
322
|
end
|
|
256
323
|
|
|
257
324
|
def handle_stream_cache_miss(component_name, raw_options, auto_load_bundle, view_cache_key, &block)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
end
|
|
271
|
-
@rorp_rendering_fibers << wrapper_fiber
|
|
272
|
-
initial_result
|
|
325
|
+
cache_aware_options = raw_options.merge(
|
|
326
|
+
on_complete: lambda { |chunks|
|
|
327
|
+
Rails.cache.write(view_cache_key, chunks, raw_options[:cache_options] || {})
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
render_stream_component_with_props(
|
|
332
|
+
component_name,
|
|
333
|
+
cache_aware_options,
|
|
334
|
+
auto_load_bundle,
|
|
335
|
+
&block
|
|
336
|
+
)
|
|
273
337
|
end
|
|
274
338
|
|
|
275
339
|
def render_stream_component_with_props(component_name, raw_options, auto_load_bundle)
|
|
@@ -291,25 +355,117 @@ module ReactOnRailsProHelper
|
|
|
291
355
|
raise ReactOnRailsPro::Error, "Option 'cache_key' is required for React on Rails caching"
|
|
292
356
|
end
|
|
293
357
|
|
|
294
|
-
|
|
295
|
-
|
|
358
|
+
# Async version of fetch_react_component. Handles cache lookup synchronously,
|
|
359
|
+
# returns ImmediateAsyncValue on hit, AsyncValue on miss.
|
|
360
|
+
def fetch_async_react_component(component_name, raw_options, &block)
|
|
361
|
+
unless defined?(@react_on_rails_async_barrier) && @react_on_rails_async_barrier
|
|
362
|
+
raise ReactOnRailsPro::Error,
|
|
363
|
+
"cached_async_react_component requires AsyncRendering concern. " \
|
|
364
|
+
"Include ReactOnRailsPro::AsyncRendering in your controller and call enable_async_react_rendering."
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Check conditional caching (:if / :unless options)
|
|
368
|
+
unless ReactOnRailsPro::Cache.use_cache?(raw_options)
|
|
369
|
+
return render_async_react_component_uncached(component_name, raw_options, &block)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
cache_key = ReactOnRailsPro::Cache.react_component_cache_key(component_name, raw_options)
|
|
373
|
+
cache_options = raw_options[:cache_options] || {}
|
|
374
|
+
Rails.logger.debug { "React on Rails Pro async cache_key is #{cache_key.inspect}" }
|
|
375
|
+
|
|
376
|
+
# Synchronous cache lookup
|
|
377
|
+
cached_result = Rails.cache.read(cache_key, cache_options)
|
|
378
|
+
if cached_result
|
|
379
|
+
Rails.logger.debug { "React on Rails Pro async cache HIT for #{cache_key.inspect}" }
|
|
380
|
+
render_options = ReactOnRails::ReactComponent::RenderOptions.new(
|
|
381
|
+
react_component_name: component_name,
|
|
382
|
+
options: raw_options
|
|
383
|
+
)
|
|
384
|
+
load_pack_for_generated_component(component_name, render_options)
|
|
385
|
+
return ReactOnRailsPro::ImmediateAsyncValue.new(cached_result)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
Rails.logger.debug { "React on Rails Pro async cache MISS for #{cache_key.inspect}" }
|
|
389
|
+
render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Renders async without caching (when :if/:unless conditions disable cache)
|
|
393
|
+
def render_async_react_component_uncached(component_name, raw_options, &block)
|
|
394
|
+
options = prepare_async_render_options(raw_options, &block)
|
|
395
|
+
|
|
396
|
+
task = @react_on_rails_async_barrier.async do
|
|
397
|
+
react_component(component_name, options)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
ReactOnRailsPro::AsyncValue.new(task: task)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Renders async and writes to cache on completion
|
|
404
|
+
def render_async_react_component_with_cache(component_name, raw_options, cache_key, cache_options, &block)
|
|
405
|
+
options = prepare_async_render_options(raw_options, &block)
|
|
406
|
+
|
|
407
|
+
task = @react_on_rails_async_barrier.async do
|
|
408
|
+
result = react_component(component_name, options)
|
|
409
|
+
Rails.cache.write(cache_key, result, cache_options)
|
|
410
|
+
result
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
ReactOnRailsPro::AsyncValue.new(task: task)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def prepare_async_render_options(raw_options)
|
|
417
|
+
raw_options.merge(
|
|
418
|
+
props: yield,
|
|
419
|
+
skip_prerender_cache: true,
|
|
420
|
+
auto_load_bundle: ReactOnRails.configuration.auto_load_bundle || raw_options[:auto_load_bundle]
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def consumer_stream_async(on_complete:)
|
|
425
|
+
require "async/variable"
|
|
426
|
+
|
|
427
|
+
if @async_barrier.nil?
|
|
296
428
|
raise ReactOnRails::Error,
|
|
297
429
|
"You must call stream_view_containing_react_components to render the view containing the react component"
|
|
298
430
|
end
|
|
299
431
|
|
|
300
|
-
|
|
432
|
+
# Create a variable to hold the first chunk for synchronous return
|
|
433
|
+
first_chunk_var = Async::Variable.new
|
|
434
|
+
all_chunks = [] if on_complete # Only collect if callback provided
|
|
435
|
+
|
|
436
|
+
# Start an async task on the barrier to stream all chunks
|
|
437
|
+
@async_barrier.async do
|
|
301
438
|
stream = yield
|
|
302
|
-
stream
|
|
303
|
-
|
|
304
|
-
end
|
|
439
|
+
process_stream_chunks(stream, first_chunk_var, all_chunks)
|
|
440
|
+
on_complete&.call(all_chunks)
|
|
305
441
|
end
|
|
306
442
|
|
|
307
|
-
|
|
443
|
+
# Wait for and return the first chunk (blocking)
|
|
444
|
+
first_chunk_var.wait
|
|
445
|
+
first_chunk_var.value
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def process_stream_chunks(stream, first_chunk_var, all_chunks)
|
|
449
|
+
is_first = true
|
|
450
|
+
|
|
451
|
+
stream.each_chunk do |chunk|
|
|
452
|
+
# Check if client disconnected before processing chunk
|
|
453
|
+
break if response.stream.closed?
|
|
454
|
+
|
|
455
|
+
all_chunks&.push(chunk)
|
|
456
|
+
|
|
457
|
+
if is_first
|
|
458
|
+
# Store first chunk in variable for synchronous return
|
|
459
|
+
first_chunk_var.value = chunk
|
|
460
|
+
is_first = false
|
|
461
|
+
else
|
|
462
|
+
# Enqueue remaining chunks to main output queue
|
|
463
|
+
@main_output_queue.enqueue(chunk)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
308
466
|
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
# all updates will be appended to the stream sent to browser
|
|
312
|
-
rendering_fiber.resume
|
|
467
|
+
# Handle case where stream has no chunks
|
|
468
|
+
first_chunk_var.value = nil if is_first
|
|
313
469
|
end
|
|
314
470
|
|
|
315
471
|
def internal_stream_react_component(component_name, options = {})
|
data/eslint.config.mjs
CHANGED
|
@@ -3,7 +3,6 @@ import { includeIgnoreFile } from '@eslint/compat';
|
|
|
3
3
|
import js from '@eslint/js';
|
|
4
4
|
import { FlatCompat } from '@eslint/eslintrc';
|
|
5
5
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
|
6
|
-
import importPlugin from 'eslint-plugin-import';
|
|
7
6
|
import jest from 'eslint-plugin-jest';
|
|
8
7
|
import prettierRecommended from 'eslint-plugin-prettier/recommended';
|
|
9
8
|
import globals from 'globals';
|
|
@@ -22,8 +21,9 @@ export default defineConfig([
|
|
|
22
21
|
'spec/react_on_rails/dummy-for-generators',
|
|
23
22
|
// includes some generated code
|
|
24
23
|
'spec/dummy/client/app/packs/server-bundle.js',
|
|
25
|
-
'packages/
|
|
26
|
-
'packages/node-renderer/
|
|
24
|
+
'../packages/react-on-rails/', // Ignore open-source package (has its own linting)
|
|
25
|
+
'../packages/react-on-rails-pro-node-renderer/lib/',
|
|
26
|
+
'../packages/react-on-rails-pro-node-renderer/tests/fixtures',
|
|
27
27
|
'**/node_modules/',
|
|
28
28
|
'**/assets/webpack/',
|
|
29
29
|
'**/generated/',
|
|
@@ -129,7 +129,7 @@ export default defineConfig([
|
|
|
129
129
|
},
|
|
130
130
|
{
|
|
131
131
|
files: ['**/*.ts{x,}'],
|
|
132
|
-
extends: [
|
|
132
|
+
extends: [typescriptEslint.configs.strictTypeChecked],
|
|
133
133
|
|
|
134
134
|
languageOptions: {
|
|
135
135
|
parserOptions: {
|
|
@@ -166,7 +166,7 @@ export default defineConfig([
|
|
|
166
166
|
},
|
|
167
167
|
},
|
|
168
168
|
{
|
|
169
|
-
files: ['packages/node-renderer/tests/**', '**/*.test.{js,jsx,ts,tsx}'],
|
|
169
|
+
files: ['../packages/react-on-rails-pro-node-renderer/tests/**', '**/*.test.{js,jsx,ts,tsx}'],
|
|
170
170
|
|
|
171
171
|
extends: [jest.configs['flat/recommended'], jest.configs['flat/style']],
|
|
172
172
|
|
|
@@ -183,8 +183,8 @@ export default defineConfig([
|
|
|
183
183
|
},
|
|
184
184
|
},
|
|
185
185
|
{
|
|
186
|
-
files: ['packages/node-renderer/src/integrations/**'],
|
|
187
|
-
ignores: ['packages/node-renderer/src/integrations/api.ts'],
|
|
186
|
+
files: ['../packages/react-on-rails-pro-node-renderer/src/integrations/**'],
|
|
187
|
+
ignores: ['../packages/react-on-rails-pro-node-renderer/src/integrations/api.ts'],
|
|
188
188
|
|
|
189
189
|
rules: {
|
|
190
190
|
// Integrations should only use the public integration API
|
|
@@ -214,6 +214,25 @@ export default defineConfig([
|
|
|
214
214
|
'react-hooks/rules-of-hooks': ['off'],
|
|
215
215
|
},
|
|
216
216
|
},
|
|
217
|
+
{
|
|
218
|
+
// Dummy apps have dependencies managed separately and may not be installed
|
|
219
|
+
files: ['spec/dummy/**/*', 'spec/execjs-compatible-dummy/**/*'],
|
|
220
|
+
rules: {
|
|
221
|
+
'import/no-unresolved': 'off',
|
|
222
|
+
'import/named': 'off',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
// Pro packages use monorepo workspace imports that ESLint can't resolve
|
|
227
|
+
// TypeScript compiler validates these imports
|
|
228
|
+
files: ['../packages/react-on-rails-pro/**/*', '../packages/react-on-rails-pro-node-renderer/**/*'],
|
|
229
|
+
rules: {
|
|
230
|
+
'import/named': 'off',
|
|
231
|
+
'import/no-unresolved': 'off',
|
|
232
|
+
'import/no-cycle': 'off',
|
|
233
|
+
'import/extensions': 'off',
|
|
234
|
+
},
|
|
235
|
+
},
|
|
217
236
|
// must be the last config in the array
|
|
218
237
|
// https://github.com/prettier/eslint-plugin-prettier?tab=readme-ov-file#configuration-new-eslintconfigjs
|
|
219
238
|
prettierRecommended,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
# AsyncValue wraps an Async task to provide a simple interface for
|
|
5
|
+
# retrieving the result of an async react_component render.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# async_value = async_react_component("MyComponent", props: { name: "World" })
|
|
9
|
+
# # ... do other work ...
|
|
10
|
+
# html = async_value.value # blocks until result is ready
|
|
11
|
+
#
|
|
12
|
+
class AsyncValue
|
|
13
|
+
def initialize(task:)
|
|
14
|
+
@task = task
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Blocks until result is ready, returns HTML string.
|
|
18
|
+
# If the async task raised an exception, it will be re-raised here.
|
|
19
|
+
def value
|
|
20
|
+
@task.wait
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resolved?
|
|
24
|
+
@task.finished?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
value.to_s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def html_safe
|
|
32
|
+
value.html_safe
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
# AsyncRendering enables concurrent rendering of multiple React components.
|
|
5
|
+
# When enabled, async_react_component calls will execute their HTTP requests
|
|
6
|
+
# in parallel instead of sequentially.
|
|
7
|
+
#
|
|
8
|
+
# @example Enable for all actions
|
|
9
|
+
# class ProductsController < ApplicationController
|
|
10
|
+
# include ReactOnRailsPro::AsyncRendering
|
|
11
|
+
# enable_async_react_rendering
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Enable for specific actions only
|
|
15
|
+
# class ProductsController < ApplicationController
|
|
16
|
+
# include ReactOnRailsPro::AsyncRendering
|
|
17
|
+
# enable_async_react_rendering only: [:show, :index]
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example Enable for all except specific actions
|
|
21
|
+
# class ProductsController < ApplicationController
|
|
22
|
+
# include ReactOnRailsPro::AsyncRendering
|
|
23
|
+
# enable_async_react_rendering except: [:create, :update]
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
module AsyncRendering
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
class_methods do
|
|
30
|
+
# Enables async React component rendering for controller actions.
|
|
31
|
+
# Accepts standard Rails filter options like :only and :except.
|
|
32
|
+
#
|
|
33
|
+
# @param options [Hash] Options passed to around_action (e.g., only:, except:)
|
|
34
|
+
def enable_async_react_rendering(**options)
|
|
35
|
+
around_action :wrap_in_async_react_context, **options
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def wrap_in_async_react_context
|
|
42
|
+
require "async"
|
|
43
|
+
require "async/barrier"
|
|
44
|
+
|
|
45
|
+
Sync do
|
|
46
|
+
@react_on_rails_async_barrier = Async::Barrier.new
|
|
47
|
+
yield
|
|
48
|
+
check_for_unresolved_async_components
|
|
49
|
+
ensure
|
|
50
|
+
@react_on_rails_async_barrier&.stop
|
|
51
|
+
@react_on_rails_async_barrier = nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def check_for_unresolved_async_components
|
|
56
|
+
return if @react_on_rails_async_barrier.nil?
|
|
57
|
+
|
|
58
|
+
pending_tasks = @react_on_rails_async_barrier.size
|
|
59
|
+
return if pending_tasks.zero?
|
|
60
|
+
|
|
61
|
+
Rails.logger.error(
|
|
62
|
+
"[React on Rails Pro] #{pending_tasks} async component(s) were started but never resolved. " \
|
|
63
|
+
"Make sure to call .value on all AsyncValue objects returned by async_react_component " \
|
|
64
|
+
"or cached_async_react_component. Unresolved tasks will be stopped."
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -31,72 +31,85 @@ module ReactOnRailsPro
|
|
|
31
31
|
#
|
|
32
32
|
# @see ReactOnRails::Helper#stream_react_component
|
|
33
33
|
def stream_view_containing_react_components(template:, close_stream_at_end: true, **render_options)
|
|
34
|
-
@rorp_rendering_fibers = []
|
|
35
|
-
template_string = render_to_string(template: template, **render_options)
|
|
36
|
-
# View may contain extra newlines, chunk already contains a newline
|
|
37
|
-
# Having multiple newlines between chunks causes hydration errors
|
|
38
|
-
# So we strip extra newlines from the template string and add a single newline
|
|
39
|
-
response.stream.write(template_string)
|
|
40
|
-
|
|
41
|
-
begin
|
|
42
|
-
drain_streams_concurrently
|
|
43
|
-
ensure
|
|
44
|
-
response.stream.close if close_stream_at_end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
def drain_streams_concurrently
|
|
51
34
|
require "async"
|
|
35
|
+
require "async/barrier"
|
|
52
36
|
require "async/limited_queue"
|
|
53
37
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# To avoid memory bloat, we use a limited queue to buffer chunks in memory.
|
|
38
|
+
Sync do |parent_task|
|
|
39
|
+
# Initialize async primitives for concurrent component streaming
|
|
40
|
+
@async_barrier = Async::Barrier.new
|
|
58
41
|
buffer_size = ReactOnRailsPro.configuration.concurrent_component_streaming_buffer_size
|
|
59
|
-
|
|
42
|
+
@main_output_queue = Async::LimitedQueue.new(buffer_size)
|
|
60
43
|
|
|
61
|
-
|
|
62
|
-
|
|
44
|
+
# Render template - components will start streaming immediately
|
|
45
|
+
template_string = render_to_string(template: template, **render_options)
|
|
46
|
+
# View may contain extra newlines, chunk already contains a newline
|
|
47
|
+
# Having multiple newlines between chunks causes hydration errors
|
|
48
|
+
# So we strip extra newlines from the template string and add a single newline
|
|
49
|
+
response.stream.write(template_string)
|
|
63
50
|
|
|
64
|
-
# This structure ensures that even if a producer task fails, we always
|
|
65
|
-
# signal the writer to stop and then wait for it to finish draining
|
|
66
|
-
# any remaining items from the queue before propagating the error.
|
|
67
51
|
begin
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
52
|
+
drain_streams_concurrently(parent_task)
|
|
53
|
+
# Do not close the response stream in an ensure block.
|
|
54
|
+
# If an error occurs we may need the stream open to send diagnostic/error details
|
|
55
|
+
# (for example, ApplicationController#rescue_from in the dummy app).
|
|
56
|
+
response.stream.close if close_stream_at_end
|
|
73
57
|
end
|
|
74
58
|
end
|
|
75
59
|
end
|
|
76
60
|
|
|
77
|
-
|
|
78
|
-
@rorp_rendering_fibers.each_with_index.map do |fiber, idx|
|
|
79
|
-
parent.async do
|
|
80
|
-
loop do
|
|
81
|
-
chunk = fiber.resume
|
|
82
|
-
break unless chunk
|
|
61
|
+
private
|
|
83
62
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
63
|
+
# Drains all streaming tasks concurrently using a producer-consumer pattern.
|
|
64
|
+
#
|
|
65
|
+
# Producer tasks: Created by consumer_stream_async in the helper, each streams
|
|
66
|
+
# chunks from the renderer and enqueues them to @main_output_queue.
|
|
67
|
+
#
|
|
68
|
+
# Consumer task: Single writer dequeues chunks and writes to response stream.
|
|
69
|
+
#
|
|
70
|
+
# Client disconnect handling:
|
|
71
|
+
# - If client disconnects (IOError/Errno::EPIPE), writer stops gracefully
|
|
72
|
+
# - Barrier is stopped to cancel all producer tasks, preventing wasted work
|
|
73
|
+
# - No exception propagates to the controller for client disconnects
|
|
74
|
+
def drain_streams_concurrently(parent_task)
|
|
75
|
+
client_disconnected = false
|
|
76
|
+
|
|
77
|
+
writing_task = parent_task.async do
|
|
78
|
+
# Drain all remaining chunks from the queue to the response stream
|
|
79
|
+
while (chunk = @main_output_queue.dequeue)
|
|
80
|
+
response.stream.write(chunk)
|
|
87
81
|
end
|
|
82
|
+
rescue IOError, Errno::EPIPE => e
|
|
83
|
+
# Client disconnected - stop writing gracefully
|
|
84
|
+
client_disconnected = true
|
|
85
|
+
log_client_disconnect("writer", e)
|
|
88
86
|
end
|
|
87
|
+
|
|
88
|
+
# Wait for all component streaming tasks to complete
|
|
89
|
+
begin
|
|
90
|
+
@async_barrier.wait
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
@async_barrier.stop
|
|
93
|
+
raise e
|
|
94
|
+
end
|
|
95
|
+
ensure
|
|
96
|
+
# Close the queue first to unblock writing_task (it may be waiting on dequeue)
|
|
97
|
+
@main_output_queue.close
|
|
98
|
+
|
|
99
|
+
# Wait for writing_task to ensure client_disconnected flag is set
|
|
100
|
+
# before we check it (fixes race condition where ensure runs before
|
|
101
|
+
# writing_task's rescue block sets the flag)
|
|
102
|
+
writing_task.wait
|
|
103
|
+
|
|
104
|
+
# If client disconnected, stop all producer tasks to avoid wasted work
|
|
105
|
+
@async_barrier.stop if client_disconnected
|
|
89
106
|
end
|
|
90
107
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
loop do
|
|
94
|
-
pair = queue.dequeue
|
|
95
|
-
break if pair.nil?
|
|
108
|
+
def log_client_disconnect(context, exception)
|
|
109
|
+
return unless ReactOnRails.configuration.logging_on_server
|
|
96
110
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
end
|
|
111
|
+
Rails.logger.debug do
|
|
112
|
+
"[React on Rails Pro] Client disconnected during streaming (#{context}): #{exception.class}"
|
|
100
113
|
end
|
|
101
114
|
end
|
|
102
115
|
end
|
|
@@ -70,7 +70,26 @@ module ReactOnRailsPro
|
|
|
70
70
|
:renderer_request_retry_limit, :throw_js_errors, :ssr_timeout,
|
|
71
71
|
:profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support,
|
|
72
72
|
:rsc_payload_generation_url_path, :rsc_bundle_js_file, :react_client_manifest_file,
|
|
73
|
-
:react_server_client_manifest_file
|
|
73
|
+
:react_server_client_manifest_file
|
|
74
|
+
|
|
75
|
+
attr_reader :concurrent_component_streaming_buffer_size
|
|
76
|
+
|
|
77
|
+
# Sets the buffer size for concurrent component streaming.
|
|
78
|
+
#
|
|
79
|
+
# This value controls how many chunks can be buffered in memory during
|
|
80
|
+
# concurrent streaming operations. When producers generate chunks faster
|
|
81
|
+
# than they can be written to the client, this buffer prevents unbounded
|
|
82
|
+
# memory growth by blocking producers when the buffer is full.
|
|
83
|
+
#
|
|
84
|
+
# @param value [Integer] A positive integer specifying the buffer size
|
|
85
|
+
# @raise [ReactOnRailsPro::Error] if value is not a positive integer
|
|
86
|
+
def concurrent_component_streaming_buffer_size=(value)
|
|
87
|
+
unless value.is_a?(Integer) && value.positive?
|
|
88
|
+
raise ReactOnRailsPro::Error,
|
|
89
|
+
"config.concurrent_component_streaming_buffer_size must be a positive integer"
|
|
90
|
+
end
|
|
91
|
+
@concurrent_component_streaming_buffer_size = value
|
|
92
|
+
end
|
|
74
93
|
|
|
75
94
|
def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, # rubocop:disable Metrics/AbcSize
|
|
76
95
|
renderer_use_fallback_exec_js: nil, prerender_caching: nil,
|
|
@@ -118,7 +137,6 @@ module ReactOnRailsPro
|
|
|
118
137
|
validate_remote_bundle_cache_adapter
|
|
119
138
|
setup_renderer_password
|
|
120
139
|
setup_assets_to_copy
|
|
121
|
-
validate_concurrent_component_streaming_buffer_size
|
|
122
140
|
setup_execjs_profiler_if_needed
|
|
123
141
|
check_react_on_rails_support_for_rsc
|
|
124
142
|
end
|
|
@@ -210,14 +228,6 @@ module ReactOnRailsPro
|
|
|
210
228
|
end
|
|
211
229
|
end
|
|
212
230
|
|
|
213
|
-
def validate_concurrent_component_streaming_buffer_size
|
|
214
|
-
return if concurrent_component_streaming_buffer_size.is_a?(Integer) &&
|
|
215
|
-
concurrent_component_streaming_buffer_size.positive?
|
|
216
|
-
|
|
217
|
-
raise ReactOnRailsPro::Error,
|
|
218
|
-
"config.concurrent_component_streaming_buffer_size must be a positive integer"
|
|
219
|
-
end
|
|
220
|
-
|
|
221
231
|
def setup_renderer_password
|
|
222
232
|
return if renderer_password.present?
|
|
223
233
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactOnRailsPro
|
|
4
|
+
# ImmediateAsyncValue is returned when a cached_async_react_component call
|
|
5
|
+
# has a cache hit. It provides the same interface as AsyncValue but returns
|
|
6
|
+
# the cached value immediately without any async operations.
|
|
7
|
+
#
|
|
8
|
+
class ImmediateAsyncValue
|
|
9
|
+
def initialize(value)
|
|
10
|
+
@value = value
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :value
|
|
14
|
+
|
|
15
|
+
def resolved?
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
@value.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def html_safe
|
|
24
|
+
@value.html_safe
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/react_on_rails_pro.rb
CHANGED
|
@@ -20,4 +20,7 @@ require "react_on_rails_pro/assets_precompile"
|
|
|
20
20
|
require "react_on_rails_pro/prepare_node_renderer_bundles"
|
|
21
21
|
require "react_on_rails_pro/concerns/stream"
|
|
22
22
|
require "react_on_rails_pro/concerns/rsc_payload_renderer"
|
|
23
|
+
require "react_on_rails_pro/concerns/async_rendering"
|
|
24
|
+
require "react_on_rails_pro/async_value"
|
|
25
|
+
require "react_on_rails_pro/immediate_async_value"
|
|
23
26
|
require "react_on_rails_pro/routes"
|