react_on_rails_pro 16.6.0 → 16.7.0.rc.0

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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.controlplane/rails.yml +2 -2
  3. data/CLAUDE.md +10 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/Gemfile.development_dependencies +20 -13
  6. data/Gemfile.lock +12 -28
  7. data/Rakefile +0 -5
  8. data/app/helpers/react_on_rails_pro_helper.rb +28 -1
  9. data/lib/react_on_rails_pro/assets_precompile.rb +170 -1
  10. data/lib/react_on_rails_pro/async_props_emitter.rb +80 -0
  11. data/lib/react_on_rails_pro/concerns/stream.rb +1 -1
  12. data/lib/react_on_rails_pro/configuration.rb +114 -17
  13. data/lib/react_on_rails_pro/engine.rb +10 -0
  14. data/lib/react_on_rails_pro/httpx_stream_bidi_patch.rb +42 -0
  15. data/lib/react_on_rails_pro/js_code_builder.rb +121 -0
  16. data/lib/react_on_rails_pro/pre_seed_renderer_cache.rb +148 -0
  17. data/lib/react_on_rails_pro/prepare_node_renderer_bundles.rb +33 -28
  18. data/lib/react_on_rails_pro/renderer_cache_helpers.rb +276 -0
  19. data/lib/react_on_rails_pro/renderer_cache_path.rb +74 -0
  20. data/lib/react_on_rails_pro/rendering_strategy/node_strategy.rb +29 -0
  21. data/lib/react_on_rails_pro/request.rb +135 -8
  22. data/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb +516 -0
  23. data/lib/react_on_rails_pro/server_rendering_js_code.rb +47 -10
  24. data/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +35 -10
  25. data/lib/react_on_rails_pro/stream_request.rb +42 -49
  26. data/lib/react_on_rails_pro/utils.rb +7 -9
  27. data/lib/react_on_rails_pro/version.rb +1 -1
  28. data/lib/react_on_rails_pro.rb +8 -0
  29. data/lib/tasks/assets.rake +36 -3
  30. data/rakelib/run_rspec.rake +6 -6
  31. data/react_on_rails_pro.gemspec +9 -4
  32. data/sig/react_on_rails_pro/configuration.rbs +2 -0
  33. metadata +35 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91309f642414eae0ab09407243def617e7f32b2b9db0d0b62b108984d31b41e0
4
- data.tar.gz: 1dec0600d12b121c3bbe62390a0c36124efed1fe40a5a53b0485d6b74e764a25
3
+ metadata.gz: c4063509561a2dee0f173ad74812beea1ae6014f59388e29d54299b0a4644071
4
+ data.tar.gz: 84ac0c7d8c79a65f42e3651c543d74723662001584df47bb4923cceca2e4c10d
5
5
  SHA512:
6
- metadata.gz: 32a8382d7d10c90e475ebcd76607e6716b1e73766c228bb43362a33ca8223efd9cd5470a1c012cc82ec1c957356047351c35e988daed3f27e1a552cde7108a32
7
- data.tar.gz: 7096efd3fb7dfe712b14f116977b9827594ae7b6505e90842f5d2213c564efc67a890fa7051c53c4373a10e303e35adee23dc27caf95f0cd68e91c70de1d57de
6
+ metadata.gz: f37225bab9df92d9fa7dbc9bd136b88daedf9f913f0fae2cd46f0ab16bc7362a4e9060bf8e0cd81b5ecb39bf8ef6ffd262572afda5a3fa962cc7f1168a0b352e
7
+ data.tar.gz: a466711a910920add21ce39477319a8ab9cd94e94bf9e75f04dbbfdfefaae891fb7d6fb3e024efd1db0c7f5756273ac87dc2a613c744c82743fcef08b7b3c2da
@@ -20,7 +20,7 @@ spec:
20
20
  protocol: http
21
21
  - name: node-renderer
22
22
  args:
23
- - client/node-renderer.js
23
+ - renderer/node-renderer.js
24
24
  command: node
25
25
  cpu: 512m
26
26
  env:
@@ -46,4 +46,4 @@ spec:
46
46
  - 0.0.0.0/0
47
47
  # Could configure outbound for more security
48
48
  outboundAllowCIDR:
49
- - 0.0.0.0/0
49
+ - 0.0.0.0/0
data/CLAUDE.md CHANGED
@@ -58,6 +58,16 @@ The node renderer is a standalone Fastify HTTP server (separate Node.js process)
58
58
  - Integrations: Sentry, Honeybadger (optional peer deps)
59
59
  - Protocol versioning: `protocolVersion` in package.json must match gem expectations
60
60
 
61
+ **Validating source changes against the dummy app:** the dummy consumes the _built_
62
+ `packages/react-on-rails-pro-node-renderer/lib/**`, so edits under `src/**` are not
63
+ picked up until the package is rebuilt. Use one of:
64
+
65
+ - `pnpm --filter react-on-rails-pro-node-renderer run build` (one-shot)
66
+ - `cd react_on_rails_pro/spec/dummy && pnpm run node-renderer:fresh` (rebuild + start)
67
+ - `pnpm --filter react-on-rails-pro-node-renderer run build-watch` (watch in another shell)
68
+
69
+ See `.claude/docs/validating-node-renderer-changes.md` for the full checklist.
70
+
61
71
  ### Yalc Dependency Chain
62
72
 
63
73
  Pro dummy's preinstall builds and links packages in this order:
data/CONTRIBUTING.md CHANGED
@@ -55,7 +55,7 @@ For links from docs pages to non-doc files, use full GitHub URLs so links resolv
55
55
  `[Installation Guide](../docs/pro/installation.md)`
56
56
 
57
57
  - When making references to source code files, use a full url path like:
58
- `[spec/dummy/config/initializers/react_on_rails.rb](https://github.com/shakacode/react_on_rails/tree/main/react_on_rails_pro/spec/dummy/config/initializers/react_on_rails.rb)`
58
+ `[spec/dummy/config/initializers/react_on_rails.rb](https://github.com/shakacode/react_on_rails/blob/main/react_on_rails_pro/spec/dummy/config/initializers/react_on_rails.rb)`
59
59
 
60
60
  ## To run tests:
61
61
 
@@ -347,7 +347,7 @@ bundle exec rspec
347
347
 
348
348
  If you run `rspec` at the top level, you'll see this message: `require': cannot load such file -- rails_helper (LoadError)`
349
349
 
350
- After running a test, you can view the coverage results in SimpleCov reports by opening `coverage/index.html`.
350
+ If you run tests with `COVERAGE=true`, you can view the SimpleCov report at `coverage/index.html`.
351
351
 
352
352
  ### Debugging
353
353
 
@@ -33,32 +33,39 @@ gem "amazing_print"
33
33
 
34
34
  group :development do
35
35
  # Access an interactive console on exception pages or by calling 'console' anywhere in the code.
36
- gem 'web-console'
37
- gem 'listen'
36
+ gem "web-console"
37
+ gem "listen"
38
38
  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
39
- gem 'spring'
40
- gem 'spring-watcher-listen'
39
+ gem "spring"
40
+ gem "spring-watcher-listen"
41
41
  end
42
42
 
43
43
  group :development, :test do
44
- gem 'faker'
45
- gem 'graphiql-rails'
46
- gem 'pry', '>= 0.14.1' # Console with powerful introspection capabilities
44
+ gem "faker"
45
+ gem "graphiql-rails"
46
+ gem "pry", ">= 0.14.1" # Console with powerful introspection capabilities
47
47
  # Need to use master of pry-byebug to use latest pry version
48
- gem 'pry-byebug', github: 'shakacode/pry-byebug' # Integrates pry with byebug
49
- gem 'pry-doc' # Provide MRI Core documentation
50
- gem 'pry-rails' # Causes rails console to open pry. `DISABLE_PRY_RAILS=1 rails c` can still open with IRB
51
- gem 'pry-theme' # An easy way to customize Pry colors via theme files
48
+ # Loaded manually in spec_helper.rb so specs can boot on readline-less Ruby builds.
49
+ gem "pry-byebug", github: "shakacode/pry-byebug", require: false # Integrates pry with byebug
50
+ gem "pry-doc" # Provide MRI Core documentation
51
+ gem "pry-rails" # Causes rails console to open pry. `DISABLE_PRY_RAILS=1 rails c` can still open with IRB
52
+ gem "pry-theme" # An easy way to customize Pry colors via theme files
52
53
 
53
54
  gem "rbs", require: false
54
55
  gem "scss_lint", require: false
55
- gem 'fakefs', require: 'fakefs/safe'
56
+ gem "fakefs", require: "fakefs/safe"
57
+
58
+ # Ruby 3.5+ removed these from the default gem set; they must now be declared explicitly
59
+ # to avoid `cannot load such file` errors from gems that lazy-require them (e.g. jbuilder).
60
+ gem "benchmark", require: false
61
+ gem "logger", require: false
62
+ gem "ostruct", require: false
56
63
  end
57
64
 
58
65
  group :test do
59
66
  gem "capybara", ">= 3.38.0"
60
67
  gem "capybara-screenshot"
61
- gem "coveralls", require: false
68
+ gem "simplecov", "~> 0.16.1", require: false
62
69
  gem "equivalent-xml"
63
70
  gem "generator_spec"
64
71
  gem "launchy"
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (16.6.0)
12
+ react_on_rails (16.7.0.rc.0)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,16 +20,16 @@ PATH
20
20
  PATH
21
21
  remote: .
22
22
  specs:
23
- react_on_rails_pro (16.6.0)
23
+ react_on_rails_pro (16.7.0.rc.0)
24
24
  addressable
25
25
  async (>= 2.29)
26
26
  connection_pool
27
27
  execjs (~> 2.9)
28
28
  http-2 (>= 1.1.1)
29
29
  httpx (~> 1.5)
30
- jwt (~> 2.7)
30
+ jwt (>= 3.2.0)
31
31
  rainbow
32
- react_on_rails (= 16.6.0)
32
+ react_on_rails (= 16.7.0.rc.0)
33
33
 
34
34
  GEM
35
35
  remote: https://rubygems.org/
@@ -149,12 +149,6 @@ GEM
149
149
  fiber-annotation
150
150
  fiber-local (~> 1.1)
151
151
  json
152
- coveralls (0.8.23)
153
- json (>= 1.8, < 3)
154
- simplecov (~> 0.16.1)
155
- term-ansicolor (~> 1.3)
156
- thor (>= 0.19.4, < 2.0)
157
- tins (~> 1.6)
158
152
  crack (1.0.0)
159
153
  bigdecimal
160
154
  rexml
@@ -188,7 +182,7 @@ GEM
188
182
  railties
189
183
  hashdiff (1.1.0)
190
184
  http-2 (1.1.1)
191
- httpx (1.6.3)
185
+ httpx (1.7.0)
192
186
  http-2 (>= 1.0.0)
193
187
  i18n (1.14.8)
194
188
  concurrent-ruby (~> 1.0)
@@ -207,7 +201,7 @@ GEM
207
201
  railties (>= 4.2.0)
208
202
  thor (>= 0.14, < 2.0)
209
203
  json (2.17.1)
210
- jwt (2.10.2)
204
+ jwt (3.2.0)
211
205
  base64
212
206
  language_server-protocol (3.17.0.5)
213
207
  launchy (3.0.1)
@@ -234,8 +228,6 @@ GEM
234
228
  minitest (6.0.2)
235
229
  drb (~> 2.0)
236
230
  prism (~> 1.5)
237
- mize (0.4.1)
238
- protocol (~> 2.0)
239
231
  msgpack (1.7.2)
240
232
  net-http (0.4.1)
241
233
  uri
@@ -255,6 +247,7 @@ GEM
255
247
  racc (~> 1.4)
256
248
  nokogiri (1.19.2-x86_64-linux-gnu)
257
249
  racc (~> 1.4)
250
+ ostruct (0.6.3)
258
251
  package_json (0.2.0)
259
252
  parallel (1.27.0)
260
253
  parser (3.3.10.0)
@@ -265,8 +258,6 @@ GEM
265
258
  prettyprint
266
259
  prettyprint (0.2.0)
267
260
  prism (1.9.0)
268
- protocol (2.0.0)
269
- ruby_parser (~> 3.0)
270
261
  pry (0.14.2)
271
262
  coderay (~> 1.1)
272
263
  method_source (~> 1.0)
@@ -390,9 +381,6 @@ GEM
390
381
  rubocop-rspec_rails (2.29.1)
391
382
  rubocop (~> 1.61)
392
383
  ruby-progressbar (1.13.0)
393
- ruby_parser (3.21.0)
394
- racc (~> 1.5)
395
- sexp_processor (~> 4.16)
396
384
  rubyzip (2.3.2)
397
385
  sass (3.7.4)
398
386
  sass-listen (~> 4.0.0)
@@ -417,7 +405,6 @@ GEM
417
405
  rubyzip (>= 1.2.2, < 3.0)
418
406
  websocket (~> 1.0)
419
407
  semantic_range (3.1.1)
420
- sexp_processor (4.17.1)
421
408
  shakapacker (9.6.1)
422
409
  activesupport (>= 5.2)
423
410
  package_json
@@ -444,16 +431,9 @@ GEM
444
431
  sqlite3 (2.9.2-x86_64-darwin)
445
432
  sqlite3 (2.9.2-x86_64-linux-gnu)
446
433
  stringio (3.2.0)
447
- sync (0.5.0)
448
- term-ansicolor (1.10.2)
449
- mize
450
- tins (~> 1.0)
451
434
  thor (1.5.0)
452
435
  tilt (2.4.0)
453
436
  timeout (0.4.4)
454
- tins (1.33.0)
455
- bigdecimal
456
- sync
457
437
  traces (0.18.2)
458
438
  tsort (0.2.0)
459
439
  turbolinks (5.2.1)
@@ -488,6 +468,7 @@ GEM
488
468
  zeitwerk (2.7.5)
489
469
 
490
470
  PLATFORMS
471
+ arm64-darwin-23
491
472
  arm64-darwin-24
492
473
  arm64-darwin-25
493
474
  x86_64-darwin-24
@@ -495,12 +476,12 @@ PLATFORMS
495
476
 
496
477
  DEPENDENCIES
497
478
  amazing_print
479
+ benchmark
498
480
  bootsnap
499
481
  bundler
500
482
  capybara (>= 3.38.0)
501
483
  capybara-screenshot
502
484
  commonmarker
503
- coveralls
504
485
  equivalent-xml
505
486
  fakefs
506
487
  faker
@@ -511,9 +492,11 @@ DEPENDENCIES
511
492
  jquery-rails
512
493
  launchy
513
494
  listen
495
+ logger
514
496
  net-http
515
497
  net-imap
516
498
  net-smtp
499
+ ostruct
517
500
  pg
518
501
  pry (>= 0.14.1)
519
502
  pry-byebug!
@@ -535,6 +518,7 @@ DEPENDENCIES
535
518
  scss_lint
536
519
  selenium-webdriver (= 4.9.0)
537
520
  shakapacker (= 9.6.1)
521
+ simplecov (~> 0.16.1)
538
522
  spring
539
523
  spring-watcher-listen
540
524
  sprockets
data/Rakefile CHANGED
@@ -3,11 +3,6 @@
3
3
  # Rake will automatically load any *.rake files inside of the "rakelib" folder
4
4
  # See rakelib/
5
5
  tasks = %w[run_rspec lint]
6
- if ENV["USE_COVERALLS"] == "TRUE"
7
- require "coveralls/rake/task"
8
- Coveralls::RakeTask.new
9
- tasks << "coveralls:push"
10
- end
11
6
 
12
7
  desc "Run all tests and linting"
13
8
  task default: tasks
@@ -141,6 +141,30 @@ module ReactOnRailsProHelper
141
141
  end
142
142
  end
143
143
 
144
+ def stream_react_component_with_async_props(component_name, options = {}, &props_block)
145
+ unless ReactOnRailsPro.configuration.enable_rsc_support
146
+ raise ReactOnRailsPro::Error,
147
+ "stream_react_component_with_async_props requires enable_rsc_support to be true. " \
148
+ "Async props depend on React Server Components. " \
149
+ "Set `config.enable_rsc_support = true` in your ReactOnRailsPro configuration."
150
+ end
151
+
152
+ options[:async_props_block] = props_block
153
+ stream_react_component(component_name, options)
154
+ end
155
+
156
+ def rsc_payload_react_component_with_async_props(component_name, options = {}, &props_block)
157
+ unless ReactOnRailsPro.configuration.enable_rsc_support
158
+ raise ReactOnRailsPro::Error,
159
+ "rsc_payload_react_component_with_async_props requires enable_rsc_support to be true. " \
160
+ "Async props depend on React Server Components. " \
161
+ "Set `config.enable_rsc_support = true` in your ReactOnRailsPro configuration."
162
+ end
163
+
164
+ options[:async_props_block] = props_block
165
+ rsc_payload_react_component(component_name, options)
166
+ end
167
+
144
168
  # Renders the React Server Component (RSC) payload for a given component. This helper generates
145
169
  # a special format designed by React for serializing server components and transmitting them
146
170
  # to the client.
@@ -516,7 +540,10 @@ module ReactOnRailsProHelper
516
540
  render_options = create_render_options(react_component_name, options)
517
541
  json_stream = server_rendered_react_component(render_options)
518
542
  json_stream.transform do |chunk|
519
- "#{chunk.to_json}\n".html_safe
543
+ html = chunk.delete("html") || ""
544
+ metadata = chunk.to_json
545
+ content_bytes = html.bytesize.to_s(16).rjust(8, "0")
546
+ "#{metadata}\t#{content_bytes}\n#{html}".html_safe
520
547
  end
521
548
  end
522
549
 
@@ -1,9 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module ReactOnRailsPro
4
6
  class AssetsPrecompile # rubocop:disable Metrics/ClassLength
5
7
  include Singleton
6
8
 
9
+ # Per-hash upload budget during assets:precompile. With RSC enabled this can
10
+ # be spent twice per deploy (server bundle + RSC bundle).
11
+ UPLOAD_TIMEOUT_SECONDS = 120
12
+
7
13
  def remote_bundle_cache_adapter
8
14
  unless ReactOnRailsPro.configuration.remote_bundle_cache_adapter.is_a?(Module)
9
15
  raise ReactOnRailsPro::Error, "config.remote_bundle_cache_adapter must have a module assigned"
@@ -62,9 +68,172 @@ module ReactOnRailsPro
62
68
 
63
69
  def self.call
64
70
  instance.build_or_fetch_bundles
71
+ return unless ReactOnRailsPro.configuration.node_renderer?
72
+
73
+ # Symlink is the same-filesystem default (local dev, CI, Heroku-style same-dyno
74
+ # deploys, bundle-caching restores). Docker image builds that run assets:precompile
75
+ # should set ASSETS_PRECOMPILE_RENDERER_CACHE_MODE=copy to bake the cache into the
76
+ # immutable artifact, or invoke `rake react_on_rails_pro:pre_seed_renderer_cache`
77
+ # directly (which defaults to copy mode).
78
+ ReactOnRailsPro::PreSeedRendererCache.call(mode: pre_seed_renderer_cache_mode)
79
+
80
+ publish_current_bundle_if_configured
81
+ end
82
+
83
+ # Best-effort publication of the just-built bundles + assets to the configured
84
+ # rolling_deploy_adapter so that the *next* deploy can fetch these hashes as
85
+ # "previous" bundles. Runs only in production-like environments. Errors are
86
+ # warned per-hash, not raised, because a failed upload degrades the next
87
+ # deploy's rolling-deploy seeding — not this deploy's correctness.
88
+ #
89
+ # Protocol: each hash is one bundle's cache entry — when RSC is enabled,
90
+ # upload is called once for the server bundle (under server_bundle_hash)
91
+ # and once for the RSC bundle (under rsc_bundle_hash).
92
+ def self.publish_current_bundle_if_configured
93
+ adapter = ReactOnRailsPro.configuration.rolling_deploy_adapter
94
+ return if adapter.nil?
95
+ # NodeRendererPool.server_bundle_hash is only available under the NodeRenderer
96
+ # renderer mode. With ExecJS, skip publication rather than crash.
97
+ return unless ReactOnRailsPro.configuration.node_renderer?
98
+ return if Rails.env.development? || Rails.env.test?
99
+
100
+ publish_bundles(adapter)
101
+ rescue StandardError => e
102
+ # Outer rescue catches anything raised by the setup-side calls below
103
+ # (collect_assets, server_bundle_hash, rsc_bundle_js_file_path). Per the
104
+ # rolling-deploy contract, a failed upload must degrade the next deploy's
105
+ # seeding — not fail *this* deploy's assets:precompile.
106
+ warn "[ReactOnRailsPro] rolling_deploy_adapter publication failed: #{e.class}: #{e.message}. " \
107
+ "Next deploy's rolling-deploy seeding may degrade; precompile continuing."
108
+ end
109
+
110
+ def self.publish_bundles(adapter)
111
+ pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
112
+ # Companion manifests are generated for the deploy as a whole, so server
113
+ # and RSC hashes from the same build intentionally share this asset set.
114
+ assets = filter_existing_assets(ReactOnRailsPro::RendererCacheHelpers.collect_assets.map(&:to_s))
65
115
 
66
- ReactOnRailsPro::PrepareNodeRenderBundles.call if ReactOnRailsPro.configuration.node_renderer?
116
+ # Defer the hash computation behind a block: `bundle_hash` reads the bundle
117
+ # file (`File.mtime` in dev/test, `Digest::MD5.file` for non-content-hashed
118
+ # names), so evaluating it eagerly as an argument would let a missing
119
+ # bundle raise and bypass the per-bundle warning path.
120
+ server_bundle = ReactOnRails::Utils.server_bundle_js_file_path
121
+ publish_bundle_if_present(adapter, server_bundle, assets, "server") { pool.server_bundle_hash }
122
+
123
+ return unless ReactOnRailsPro.configuration.enable_rsc_support
124
+
125
+ rsc_bundle = ReactOnRailsPro::Utils.rsc_bundle_js_file_path
126
+ publish_bundle_if_present(adapter, rsc_bundle, assets, "RSC") { pool.rsc_bundle_hash }
127
+ end
128
+
129
+ # Some collected companion assets may be absent or point at non-file paths.
130
+ # Typical adapters iterate the list and `cp`/open each entry, so forwarding
131
+ # an invalid path would raise and abort the whole hash upload, leaving the
132
+ # next deploy unable to fetch this hash (→ cold 410 retries). Drop invalid
133
+ # entries with a warning so publication still covers the existing assets.
134
+ #
135
+ # Mirrors RendererCacheHelpers.each_stageable_asset: skip URL-backed assets
136
+ # (dev server) and expand relative paths against Rails.root before checking
137
+ # existence. Without the expansion, `assets:precompile` invoked from a
138
+ # non-Rails.root cwd would silently drop relative entries in `assets_to_copy`.
139
+ def self.filter_existing_assets(assets)
140
+ resolvable = assets.reject { |path| ReactOnRailsPro::RendererCacheHelpers.http_url?(path) }
141
+ resolved = resolvable.map { |path| File.expand_path(path.to_s, Rails.root) }
142
+
143
+ existing, invalid = resolved.partition { |path| File.file?(path) }
144
+ return existing if invalid.empty?
145
+
146
+ missing, non_files = invalid.partition { |path| !File.exist?(path) }
147
+ warn_skipped_invalid_assets(existing, missing, non_files)
148
+ warn_if_unavailable_required_rsc_assets(invalid)
149
+ existing
150
+ end
151
+
152
+ # Combine missing-vs-non-file reasons into a single warning so operators see
153
+ # one entry per skipped batch instead of two near-identical lines. The
154
+ # reason breakdown (missing vs non-file) still appears so adapter authors
155
+ # can tell a deleted asset apart from one that resolved to e.g. a directory.
156
+ def self.warn_skipped_invalid_assets(existing, missing, non_files)
157
+ reasons = []
158
+ reasons << "missing: #{missing.inspect}" unless missing.empty?
159
+ reasons << "not a file: #{non_files.inspect}" unless non_files.empty?
160
+ warn "[ReactOnRailsPro] Skipping invalid assets for rolling_deploy_adapter upload " \
161
+ "(some may be required for RSC) — #{reasons.join('; ')}. " \
162
+ "Continuing with #{existing.length} existing asset(s)."
163
+ end
164
+
165
+ # Match by full expanded path (filter_existing_assets passes expanded paths
166
+ # in `unavailable_assets`) rather than basename, so an unrelated missing
167
+ # entry in `assets_to_copy` that happens to share a basename with a required
168
+ # RSC manifest can't false-positive this warning when the real required
169
+ # file is present elsewhere.
170
+ def self.warn_if_unavailable_required_rsc_assets(unavailable_assets)
171
+ required_paths = ReactOnRailsPro::RendererCacheHelpers.required_rsc_asset_paths_for_current_config
172
+ missing_required_paths = unavailable_assets.select { |path| required_paths.include?(path) }
173
+ return if missing_required_paths.empty?
174
+
175
+ missing_required = missing_required_paths.map { |path| File.basename(path) }
176
+ warn "[ReactOnRailsPro] WARNING: unavailable assets include required RSC companion file(s) " \
177
+ "#{missing_required.inspect}. The partial entry will be rejected on every subsequent rolling " \
178
+ "deploy that tries to seed this bundle hash for RSC (falling back to 410-retry) until a " \
179
+ "complete precompile with all required RSC companion files overwrites this hash."
180
+ end
181
+
182
+ def self.publish_bundle(adapter, hash, bundle, assets, bundle_label)
183
+ if hash.to_s.empty?
184
+ warn "[ReactOnRailsPro] Skipping rolling_deploy_adapter publication for #{bundle_label} bundle " \
185
+ "#{bundle.inspect} because its bundle hash is blank."
186
+ return
187
+ end
188
+
189
+ upload_bundle(adapter, hash, bundle, assets)
190
+ end
191
+
192
+ def self.publish_bundle_if_present(adapter, bundle, assets, bundle_label)
193
+ if File.file?(bundle)
194
+ publish_bundle(adapter, yield, bundle, assets, bundle_label)
195
+ else
196
+ display_label = bundle_label == "RSC" ? "RSC" : bundle_label.capitalize
197
+ reason = File.exist?(bundle) ? "is not a file" : "does not exist"
198
+ # Use `bundle_label` (the caller's original casing) for the second
199
+ # reference so the warning reads consistently (e.g. "RSC bundle ...
200
+ # skipping ... for RSC bundle" rather than mixing "RSC" and "rsc").
201
+ warn "[ReactOnRailsPro] #{display_label} bundle #{bundle.inspect} #{reason}; " \
202
+ "skipping rolling_deploy_adapter publication for #{bundle_label} bundle."
203
+ end
204
+ end
205
+
206
+ def self.upload_bundle(adapter, hash, bundle, assets)
207
+ Timeout.timeout(UPLOAD_TIMEOUT_SECONDS) do
208
+ adapter.upload(hash, bundle: bundle, assets: assets)
209
+ end
210
+ puts "[ReactOnRailsPro] Published bundle hash #{hash} via rolling_deploy_adapter"
211
+ rescue Timeout::Error
212
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#upload for #{hash} timed out after " \
213
+ "#{UPLOAD_TIMEOUT_SECONDS}s. Next deploy's rolling-deploy seeding for this hash may degrade."
214
+ rescue StandardError => e
215
+ warn "[ReactOnRailsPro] rolling_deploy_adapter#upload for #{hash} raised #{e.class}: " \
216
+ "#{e.message}. Next deploy's rolling-deploy seeding for this hash may degrade."
217
+ end
218
+ private_class_method :publish_current_bundle_if_configured,
219
+ :publish_bundles,
220
+ :filter_existing_assets,
221
+ :warn_skipped_invalid_assets,
222
+ :warn_if_unavailable_required_rsc_assets,
223
+ :publish_bundle,
224
+ :publish_bundle_if_present,
225
+ :upload_bundle
226
+
227
+ def self.pre_seed_renderer_cache_mode
228
+ raw = ENV.fetch("ASSETS_PRECOMPILE_RENDERER_CACHE_MODE", "symlink").to_s.downcase
229
+ mode = raw.to_sym
230
+ return mode if ReactOnRailsPro::PreSeedRendererCache::VALID_MODES.include?(mode)
231
+
232
+ valid = ReactOnRailsPro::PreSeedRendererCache::VALID_MODES.map(&:to_s).join(", ")
233
+ raise ReactOnRailsPro::Error,
234
+ "ASSETS_PRECOMPILE_RENDERER_CACHE_MODE must be one of: #{valid} (got #{raw.inspect})"
67
235
  end
236
+ private_class_method :pre_seed_renderer_cache_mode
68
237
 
69
238
  def build_or_fetch_bundles
70
239
  if disable_precompile_cache?
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRailsPro
4
+ # Emitter class for sending async props incrementally during streaming render.
5
+ # Used by stream_react_component_with_async_props helper.
6
+ #
7
+ # PROTOCOL:
8
+ # Each call to `emit.call(prop_name, value)` sends an NDJSON line to the Node renderer:
9
+ # {"bundleTimestamp": "abc123", "updateChunk": "(function(){...})()"}
10
+ #
11
+ # The updateChunk JavaScript accesses the AsyncPropsManager via sharedExecutionContext
12
+ # and resolves the promise for that prop, allowing React to continue rendering.
13
+ #
14
+ # WHY NOT USE GLOBAL VARIABLES?
15
+ # Global variables in Node.js VM persist across requests, causing data leakage.
16
+ # sharedExecutionContext is scoped to a single HTTP request (ExecutionContext).
17
+ #
18
+ # @example Usage in view
19
+ # stream_react_component_with_async_props("Dashboard") do |emit|
20
+ # emit.call("users", User.all.to_a) # Sends immediately
21
+ # emit.call("posts", Post.recent.to_a) # Sends when ready
22
+ # end
23
+ class AsyncPropsEmitter
24
+ def initialize(bundle_timestamp, request_stream)
25
+ @bundle_timestamp = bundle_timestamp
26
+ @request_stream = request_stream
27
+ end
28
+
29
+ # Sends an async prop to the Node renderer.
30
+ # The prop value is JSON-serialized and sent as an NDJSON line.
31
+ # On the Node side, this triggers asyncPropsManager.setProp(propName, value).
32
+ def call(prop_name, prop_value)
33
+ update_chunk = generate_update_chunk(prop_name, prop_value)
34
+ @request_stream << "#{update_chunk.to_json}\n"
35
+ rescue StandardError => e
36
+ Rails.logger.error do
37
+ backtrace = e.backtrace&.first(5)&.join("\n")
38
+ "[ReactOnRailsPro::AsyncProps] Failed to send async prop '#{prop_name}': " \
39
+ "#{e.class} - #{e.message}\n#{backtrace}"
40
+ end
41
+ # Continue - don't abort entire render because one prop failed
42
+ end
43
+
44
+ # Generates the chunk that should be executed when the request stream closes
45
+ # This tells the asyncPropsManager to end the stream
46
+ def end_stream_chunk
47
+ {
48
+ bundleTimestamp: @bundle_timestamp,
49
+ updateChunk: generate_end_stream_js
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def generate_update_chunk(prop_name, value)
56
+ {
57
+ bundleTimestamp: @bundle_timestamp,
58
+ updateChunk: generate_set_prop_js(prop_name, value)
59
+ }
60
+ end
61
+
62
+ def generate_set_prop_js(prop_name, value)
63
+ <<~JS.strip
64
+ (function(){
65
+ var asyncPropsManager = ReactOnRails.getOrCreateAsyncPropsManager(sharedExecutionContext);
66
+ asyncPropsManager.setProp(#{prop_name.to_json}, #{value.to_json});
67
+ })()
68
+ JS
69
+ end
70
+
71
+ def generate_end_stream_js
72
+ <<~JS.strip
73
+ (function(){
74
+ var asyncPropsManager = ReactOnRails.getOrCreateAsyncPropsManager(sharedExecutionContext);
75
+ asyncPropsManager.endStream();
76
+ })()
77
+ JS
78
+ end
79
+ end
80
+ end
@@ -62,7 +62,7 @@ module ReactOnRailsPro
62
62
  # is when ActionController::Live commits headers. render_to_string itself
63
63
  # never writes to response.stream, so this assignment is always safe.
64
64
  response.content_type = content_type if content_type
65
- response.stream.write(template_string)
65
+ response.stream.write(template_string.lstrip)
66
66
 
67
67
  drain_streams_concurrently(parent_task)
68
68
  # Do not close the response stream in an ensure block.