react_on_rails 16.2.0.beta.10 → 16.2.0.beta.12

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -21
  3. data/CLAUDE.md +180 -5
  4. data/CONTRIBUTING.md +3 -1
  5. data/Gemfile.lock +1 -1
  6. data/Steepfile +4 -0
  7. data/analysis/rake-task-duplicate-analysis.md +149 -0
  8. data/analysis/v8-crash-retry-solution.md +148 -0
  9. data/bin/ci-run-failed-specs +6 -4
  10. data/bin/ci-switch-config +4 -3
  11. data/lib/generators/react_on_rails/base_generator.rb +2 -1
  12. data/lib/generators/react_on_rails/generator_helper.rb +29 -0
  13. data/lib/generators/react_on_rails/js_dependency_manager.rb +29 -7
  14. data/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt +19 -0
  15. data/lib/generators/react_on_rails/templates/base/base/config/{shakapacker.yml → shakapacker.yml.tt} +9 -0
  16. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +38 -4
  17. data/lib/react_on_rails/configuration.rb +89 -10
  18. data/lib/react_on_rails/dev/pack_generator.rb +49 -11
  19. data/lib/react_on_rails/dev/server_manager.rb +1 -0
  20. data/lib/react_on_rails/doctor.rb +94 -4
  21. data/lib/react_on_rails/helper.rb +32 -5
  22. data/lib/react_on_rails/packs_generator.rb +47 -35
  23. data/lib/react_on_rails/system_checker.rb +7 -4
  24. data/lib/react_on_rails/utils.rb +54 -0
  25. data/lib/react_on_rails/version.rb +1 -1
  26. data/lib/tasks/generate_packs.rake +12 -6
  27. data/react_on_rails_pro/CHANGELOG.md +11 -4
  28. data/react_on_rails_pro/Gemfile.lock +3 -3
  29. data/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb +2 -1
  30. data/react_on_rails_pro/lib/react_on_rails_pro/version.rb +1 -1
  31. data/react_on_rails_pro/package.json +1 -1
  32. data/react_on_rails_pro/spec/dummy/Gemfile.lock +3 -3
  33. data/react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +15 -7
  34. data/react_on_rails_pro/spec/dummy/spec/requests/renderer_console_logging_spec.rb +9 -4
  35. data/sig/react_on_rails/dev/file_manager.rbs +15 -0
  36. data/sig/react_on_rails/dev/pack_generator.rbs +19 -0
  37. data/sig/react_on_rails/dev/process_manager.rbs +22 -0
  38. data/sig/react_on_rails/dev/server_manager.rbs +39 -0
  39. metadata +9 -3
@@ -226,7 +226,7 @@ module ReactOnRails
226
226
  }
227
227
  }
228
228
 
229
- consoleReplayScript = ReactOnRails.buildConsoleReplay();
229
+ consoleReplayScript = ReactOnRails.getConsoleReplayScript();
230
230
 
231
231
  return JSON.stringify({
232
232
  html: htmlResult,
@@ -242,8 +242,9 @@ module ReactOnRails
242
242
  .server_render_js_with_console_logging(js_code, render_options)
243
243
 
244
244
  html = result["html"]
245
- console_log_script = result["consoleLogScript"]
246
- raw("#{html}#{console_log_script if render_options.replay_console}")
245
+ console_script = result["consoleReplayScript"]
246
+ console_script_tag = wrap_console_script_with_nonce(console_script) if render_options.replay_console
247
+ raw("#{html}#{console_script_tag}")
247
248
  rescue ExecJS::ProgramError => err
248
249
  raise ReactOnRails::PrerenderError.new(component_name: "N/A (server_render_js called)",
249
250
  err: err,
@@ -394,7 +395,7 @@ module ReactOnRails
394
395
  server_rendered_html.html_safe,
395
396
  content_tag_options)
396
397
 
397
- result_console_script = render_options.replay_console ? console_script : ""
398
+ result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : ""
398
399
  result = compose_react_component_html_with_spec_and_console(
399
400
  component_specification_tag, rendered_output, result_console_script
400
401
  )
@@ -419,7 +420,7 @@ module ReactOnRails
419
420
  server_rendered_html[COMPONENT_HTML_KEY].html_safe,
420
421
  content_tag_options)
421
422
 
422
- result_console_script = render_options.replay_console ? console_script : ""
423
+ result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : ""
423
424
  result = compose_react_component_html_with_spec_and_console(
424
425
  component_specification_tag, rendered_output, result_console_script
425
426
  )
@@ -436,6 +437,32 @@ module ReactOnRails
436
437
  )
437
438
  end
438
439
 
440
+ # Wraps console replay JavaScript code in a script tag with CSP nonce if available.
441
+ # The console_script_code is already sanitized by scriptSanitizedVal() in the JavaScript layer,
442
+ # so using html_safe here is secure.
443
+ def wrap_console_script_with_nonce(console_script_code)
444
+ return "" if console_script_code.blank?
445
+
446
+ # Get the CSP nonce if available (Rails 5.2+)
447
+ # Rails 5.2-6.0 use content_security_policy_nonce with no arguments
448
+ # Rails 6.1+ accept an optional directive argument
449
+ nonce = if respond_to?(:content_security_policy_nonce)
450
+ begin
451
+ content_security_policy_nonce(:script)
452
+ rescue ArgumentError
453
+ # Fallback for Rails versions that don't accept arguments
454
+ content_security_policy_nonce
455
+ end
456
+ end
457
+
458
+ # Build the script tag with nonce if available
459
+ script_options = { id: "consoleReplayLog" }
460
+ script_options[:nonce] = nonce if nonce.present?
461
+
462
+ # Safe to use html_safe because content is pre-sanitized via scriptSanitizedVal()
463
+ content_tag(:script, console_script_code.html_safe, script_options)
464
+ end
465
+
439
466
  def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output,
440
467
  console_script)
441
468
  # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
@@ -25,40 +25,42 @@ module ReactOnRails
25
25
  def generate_packs_if_stale
26
26
  return unless ReactOnRails.configuration.auto_load_bundle
27
27
 
28
+ verbose = ENV["REACT_ON_RAILS_VERBOSE"] == "true"
29
+
28
30
  add_generated_pack_to_server_bundle
29
31
 
30
32
  # Clean any non-generated files from directories
31
- clean_non_generated_files_with_feedback
33
+ clean_non_generated_files_with_feedback(verbose: verbose)
32
34
 
33
35
  are_generated_files_present_and_up_to_date = Dir.exist?(generated_packs_directory_path) &&
34
36
  File.exist?(generated_server_bundle_file_path) &&
35
37
  !stale_or_missing_packs?
36
38
 
37
39
  if are_generated_files_present_and_up_to_date
38
- puts Rainbow("✅ Generated packs are up to date, no regeneration needed").green
40
+ puts Rainbow("✅ Generated packs are up to date, no regeneration needed").green if verbose
39
41
  return
40
42
  end
41
43
 
42
- clean_generated_directories_with_feedback
43
- generate_packs
44
+ clean_generated_directories_with_feedback(verbose: verbose)
45
+ generate_packs(verbose: verbose)
44
46
  end
45
47
 
46
48
  private
47
49
 
48
- def generate_packs
49
- common_component_to_path.each_value { |component_path| create_pack(component_path) }
50
- client_component_to_path.each_value { |component_path| create_pack(component_path) }
50
+ def generate_packs(verbose: false)
51
+ common_component_to_path.each_value { |component_path| create_pack(component_path, verbose: verbose) }
52
+ client_component_to_path.each_value { |component_path| create_pack(component_path, verbose: verbose) }
51
53
 
52
- create_server_pack if ReactOnRails.configuration.server_bundle_js_file.present?
54
+ create_server_pack(verbose: verbose) if ReactOnRails.configuration.server_bundle_js_file.present?
53
55
  end
54
56
 
55
- def create_pack(file_path)
57
+ def create_pack(file_path, verbose: false)
56
58
  output_path = generated_pack_path(file_path)
57
59
  content = pack_file_contents(file_path)
58
60
 
59
61
  File.write(output_path, content)
60
62
 
61
- puts(Rainbow("Generated Packs: #{output_path}").yellow)
63
+ puts(Rainbow("Generated Packs: #{output_path}").yellow) if verbose
62
64
  end
63
65
 
64
66
  def first_js_statement_in_code(content) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
@@ -126,11 +128,11 @@ module ReactOnRails
126
128
  FILE_CONTENT
127
129
  end
128
130
 
129
- def create_server_pack
131
+ def create_server_pack(verbose: false)
130
132
  File.write(generated_server_bundle_file_path, generated_server_pack_file_content)
131
133
 
132
134
  add_generated_pack_to_server_bundle
133
- puts(Rainbow("Generated Server Bundle: #{generated_server_bundle_file_path}").orange)
135
+ puts(Rainbow("Generated Server Bundle: #{generated_server_bundle_file_path}").orange) if verbose
134
136
  end
135
137
 
136
138
  def build_server_pack_content(component_on_server_imports, server_components, client_components)
@@ -200,17 +202,17 @@ module ReactOnRails
200
202
  "#{generated_nonentrypoints_path}/#{generated_server_bundle_file_name}.js"
201
203
  end
202
204
 
203
- def clean_non_generated_files_with_feedback
205
+ def clean_non_generated_files_with_feedback(verbose: false)
204
206
  directories_to_clean = [generated_packs_directory_path, generated_server_bundle_directory_path].compact.uniq
205
207
  expected_files = build_expected_files_set
206
208
 
207
- puts Rainbow("🧹 Cleaning non-generated files...").yellow
209
+ puts Rainbow("🧹 Cleaning non-generated files...").yellow if verbose
208
210
 
209
211
  total_deleted = directories_to_clean.sum do |dir_path|
210
- clean_unexpected_files_from_directory(dir_path, expected_files)
212
+ clean_unexpected_files_from_directory(dir_path, expected_files, verbose: verbose)
211
213
  end
212
214
 
213
- display_cleanup_summary(total_deleted)
215
+ display_cleanup_summary(total_deleted, verbose: verbose) if verbose
214
216
  end
215
217
 
216
218
  def build_expected_files_set
@@ -225,17 +227,17 @@ module ReactOnRails
225
227
  { pack_files: expected_pack_files, server_bundle: expected_server_bundle }
226
228
  end
227
229
 
228
- def clean_unexpected_files_from_directory(dir_path, expected_files)
230
+ def clean_unexpected_files_from_directory(dir_path, expected_files, verbose: false)
229
231
  return 0 unless Dir.exist?(dir_path)
230
232
 
231
233
  existing_files = Dir.glob("#{dir_path}/**/*").select { |f| File.file?(f) }
232
234
  unexpected_files = find_unexpected_files(existing_files, dir_path, expected_files)
233
235
 
234
236
  if unexpected_files.any?
235
- delete_unexpected_files(unexpected_files, dir_path)
237
+ delete_unexpected_files(unexpected_files, dir_path, verbose: verbose)
236
238
  unexpected_files.length
237
239
  else
238
- puts Rainbow(" No unexpected files found in #{dir_path}").cyan
240
+ puts Rainbow(" No unexpected files found in #{dir_path}").cyan if verbose
239
241
  0
240
242
  end
241
243
  end
@@ -250,15 +252,21 @@ module ReactOnRails
250
252
  end
251
253
  end
252
254
 
253
- def delete_unexpected_files(unexpected_files, dir_path)
254
- puts Rainbow(" Deleting #{unexpected_files.length} unexpected files from #{dir_path}:").cyan
255
- unexpected_files.each do |file|
256
- puts Rainbow(" - #{File.basename(file)}").blue
257
- File.delete(file)
255
+ def delete_unexpected_files(unexpected_files, dir_path, verbose: false)
256
+ if verbose
257
+ puts Rainbow(" Deleting #{unexpected_files.length} unexpected files from #{dir_path}:").cyan
258
+ unexpected_files.each do |file|
259
+ puts Rainbow(" - #{File.basename(file)}").blue
260
+ File.delete(file)
261
+ end
262
+ else
263
+ unexpected_files.each { |file| File.delete(file) }
258
264
  end
259
265
  end
260
266
 
261
- def display_cleanup_summary(total_deleted)
267
+ def display_cleanup_summary(total_deleted, verbose: false)
268
+ return unless verbose
269
+
262
270
  if total_deleted.positive?
263
271
  puts Rainbow("🗑️ Deleted #{total_deleted} unexpected files total").red
264
272
  else
@@ -266,15 +274,17 @@ module ReactOnRails
266
274
  end
267
275
  end
268
276
 
269
- def clean_generated_directories_with_feedback
277
+ def clean_generated_directories_with_feedback(verbose: false)
270
278
  directories_to_clean = [
271
279
  generated_packs_directory_path,
272
280
  generated_server_bundle_directory_path
273
281
  ].compact.uniq
274
282
 
275
- puts Rainbow("🧹 Cleaning generated directories...").yellow
283
+ puts Rainbow("🧹 Cleaning generated directories...").yellow if verbose
284
+
285
+ total_deleted = directories_to_clean.sum { |dir_path| clean_directory_with_feedback(dir_path, verbose: verbose) }
276
286
 
277
- total_deleted = directories_to_clean.sum { |dir_path| clean_directory_with_feedback(dir_path) }
287
+ return unless verbose
278
288
 
279
289
  if total_deleted.positive?
280
290
  puts Rainbow("🗑️ Deleted #{total_deleted} generated files total").red
@@ -283,27 +293,29 @@ module ReactOnRails
283
293
  end
284
294
  end
285
295
 
286
- def clean_directory_with_feedback(dir_path)
287
- return create_directory_with_feedback(dir_path) unless Dir.exist?(dir_path)
296
+ def clean_directory_with_feedback(dir_path, verbose: false)
297
+ return create_directory_with_feedback(dir_path, verbose: verbose) unless Dir.exist?(dir_path)
288
298
 
289
299
  files = Dir.glob("#{dir_path}/**/*").select { |f| File.file?(f) }
290
300
 
291
301
  if files.any?
292
- puts Rainbow(" Deleting #{files.length} files from #{dir_path}:").cyan
293
- files.each { |file| puts Rainbow(" - #{File.basename(file)}").blue }
302
+ if verbose
303
+ puts Rainbow(" Deleting #{files.length} files from #{dir_path}:").cyan
304
+ files.each { |file| puts Rainbow(" - #{File.basename(file)}").blue }
305
+ end
294
306
  FileUtils.rm_rf(dir_path)
295
307
  FileUtils.mkdir_p(dir_path)
296
308
  files.length
297
309
  else
298
- puts Rainbow(" Directory #{dir_path} is already empty").cyan
310
+ puts Rainbow(" Directory #{dir_path} is already empty").cyan if verbose
299
311
  FileUtils.rm_rf(dir_path)
300
312
  FileUtils.mkdir_p(dir_path)
301
313
  0
302
314
  end
303
315
  end
304
316
 
305
- def create_directory_with_feedback(dir_path)
306
- puts Rainbow(" Directory #{dir_path} does not exist, creating...").cyan
317
+ def create_directory_with_feedback(dir_path, verbose: false)
318
+ puts Rainbow(" Directory #{dir_path} does not exist, creating...").cyan if verbose
307
319
  FileUtils.mkdir_p(dir_path)
308
320
  0
309
321
  end
@@ -213,17 +213,20 @@ module ReactOnRails
213
213
 
214
214
  return unless npm_version && defined?(ReactOnRails::VERSION)
215
215
 
216
- # Clean version strings for comparison (remove ^, ~, =, etc.)
217
- clean_npm_version = npm_version.gsub(/[^0-9.]/, "")
216
+ # Normalize NPM version format to Ruby gem format for comparison
217
+ # Uses existing VersionSyntaxConverter to handle dash/dot differences
218
+ # (e.g., "16.2.0-beta.10" → "16.2.0.beta.10")
219
+ converter = ReactOnRails::VersionSyntaxConverter.new
220
+ normalized_npm_version = converter.npm_to_rubygem(npm_version)
218
221
  gem_version = ReactOnRails::VERSION
219
222
 
220
- if clean_npm_version == gem_version
223
+ if normalized_npm_version == gem_version
221
224
  add_success("✅ React on Rails gem and NPM package versions match (#{gem_version})")
222
225
  check_version_patterns(npm_version, gem_version)
223
226
  else
224
227
  # Check for major version differences
225
228
  gem_major = gem_version.split(".")[0].to_i
226
- npm_major = clean_npm_version.split(".")[0].to_i
229
+ npm_major = normalized_npm_version.split(".")[0].to_i
227
230
 
228
231
  if gem_major != npm_major # rubocop:disable Style/NegatedIfElseCondition
229
232
  add_error(<<~MSG.strip)
@@ -443,6 +443,60 @@ module ReactOnRails
443
443
  end
444
444
  end
445
445
 
446
+ # Converts an absolute path (String or Pathname) to a path relative to Rails.root.
447
+ # If the path is already relative or doesn't contain Rails.root, returns it as-is.
448
+ #
449
+ # This method is used to normalize paths from Shakapacker's privateOutputPath (which is
450
+ # absolute) to relative paths suitable for React on Rails configuration.
451
+ #
452
+ # Note: Absolute paths that don't start with Rails.root are intentionally passed through
453
+ # unchanged. While there's no known use case for server bundles outside Rails.root,
454
+ # this behavior preserves the original path for debugging and error messages.
455
+ #
456
+ # @param path [String, Pathname] The path to normalize
457
+ # @return [String, nil] The relative path as a string, or nil if path is nil
458
+ #
459
+ # @example Converting absolute paths within Rails.root
460
+ # # Assuming Rails.root is "/app"
461
+ # normalize_to_relative_path("/app/ssr-generated") # => "ssr-generated"
462
+ # normalize_to_relative_path("/app/foo/bar") # => "foo/bar"
463
+ #
464
+ # @example Already relative paths pass through
465
+ # normalize_to_relative_path("ssr-generated") # => "ssr-generated"
466
+ # normalize_to_relative_path("./ssr-generated") # => "./ssr-generated"
467
+ #
468
+ # @example Absolute paths outside Rails.root (edge case)
469
+ # normalize_to_relative_path("/other/path/bundles") # => "/other/path/bundles"
470
+ # rubocop:disable Metrics/CyclomaticComplexity
471
+ def self.normalize_to_relative_path(path)
472
+ return nil if path.nil?
473
+
474
+ path_str = path.to_s
475
+ rails_root_str = Rails.root.to_s.chomp("/")
476
+
477
+ # Treat as "inside Rails.root" only for exact match or a subdirectory
478
+ inside_rails_root = rails_root_str.present? &&
479
+ (path_str == rails_root_str || path_str.start_with?("#{rails_root_str}/"))
480
+
481
+ # If path is within Rails.root, remove that prefix
482
+ if inside_rails_root
483
+ # Remove Rails.root and any leading slash
484
+ path_str.sub(%r{^#{Regexp.escape(rails_root_str)}/?}, "")
485
+ else
486
+ # Path is already relative or outside Rails.root
487
+ # Warn if it's an absolute path outside Rails.root (edge case)
488
+ if path_str.start_with?("/") && !inside_rails_root
489
+ Rails.logger&.warn(
490
+ "ReactOnRails: Detected absolute path outside Rails.root: '#{path_str}'. " \
491
+ "Server bundles are typically stored within Rails.root. " \
492
+ "Verify this is intentional."
493
+ )
494
+ end
495
+ path_str
496
+ end
497
+ end
498
+ # rubocop:enable Metrics/CyclomaticComplexity
499
+
446
500
  def self.default_troubleshooting_section
447
501
  <<~DEFAULT
448
502
  📞 Get Help & Support:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactOnRails
4
- VERSION = "16.2.0.beta.10"
4
+ VERSION = "16.2.0.beta.12"
5
5
  end
@@ -17,18 +17,24 @@ namespace :react_on_rails do
17
17
  DESC
18
18
 
19
19
  task generate_packs: :environment do
20
- puts Rainbow("🚀 Starting React on Rails pack generation...").bold
21
- puts Rainbow("📁 Auto-load bundle: #{ReactOnRails.configuration.auto_load_bundle}").cyan
22
- puts Rainbow("📂 Components subdirectory: #{ReactOnRails.configuration.components_subdirectory}").cyan
23
- puts ""
20
+ verbose = ENV["REACT_ON_RAILS_VERBOSE"] == "true"
21
+
22
+ if verbose
23
+ puts Rainbow("🚀 Starting React on Rails pack generation...").bold
24
+ puts Rainbow("📁 Auto-load bundle: #{ReactOnRails.configuration.auto_load_bundle}").cyan
25
+ puts Rainbow("📂 Components subdirectory: #{ReactOnRails.configuration.components_subdirectory}").cyan
26
+ puts ""
27
+ end
24
28
 
25
29
  begin
26
30
  start_time = Time.now
27
31
  ReactOnRails::PacksGenerator.instance.generate_packs_if_stale
28
32
  end_time = Time.now
29
33
 
30
- puts ""
31
- puts Rainbow("✨ Pack generation completed in #{((end_time - start_time) * 1000).round(1)}ms").green
34
+ if verbose
35
+ puts ""
36
+ puts Rainbow("✨ Pack generation completed in #{((end_time - start_time) * 1000).round(1)}ms").green
37
+ end
32
38
  rescue ReactOnRails::Error => e
33
39
  handle_react_on_rails_error(e)
34
40
  exit 1
@@ -20,13 +20,12 @@ You can find the **package** version numbers from this repo's tags and below in
20
20
  _Add changes in master not yet tagged._
21
21
 
22
22
  ### Improved
23
- - Significantly improved streaming performance by processing React components concurrently instead of sequentially. This reduces latency and improves responsiveness when using `stream_view_containing_react_components`.
24
23
 
25
- ### Added
26
- - 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.
24
+ - **Concurrent Streaming Performance**: Implemented concurrent draining of streamed React components using the async gem. Instead of processing components sequentially, the system now uses a producer-consumer pattern with bounded buffering to allow multiple components to stream simultaneously while maintaining per-component chunk ordering. This significantly reduces latency and improves responsiveness when using `stream_view_containing_react_components`. [PR 2015](https://github.com/shakacode/react_on_rails/pull/2015) by [ihabadham](https://github.com/ihabadham).
27
25
 
28
26
  ### Added
29
27
 
28
+ - 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.
30
29
  - Added `cached_stream_react_component` helper method, similar to `cached_react_component` but for streamed components.
31
30
  - **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).
32
31
  - **Pro-Specific Configurations Moved from Open-Source**: The following React Server Components (RSC) configurations are now exclusively in the Pro gem and should be configured in `ReactOnRailsPro.configure`:
@@ -46,9 +45,17 @@ _Add changes in master not yet tagged._
46
45
 
47
46
  - **Node Renderer Gem Version Validation**: The node renderer now validates that the Ruby gem version (`react_on_rails_pro`) matches the node renderer package version (`@shakacode-tools/react-on-rails-pro-node-renderer`) on every render request. Environment-aware: strict enforcement in development (returns 412 Precondition Failed on mismatch), permissive in production (allows with warning). Includes version normalization to handle Ruby gem vs NPM format differences (e.g., `4.0.0.rc.1` vs `4.0.0-rc.1`). [PR #1881](https://github.com/shakacode/react_on_rails/pull/1881) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
48
47
 
48
+ ### Fixed
49
+
50
+ - **Node Renderer Worker Restart**: Fixed "descriptor closed" error that occurred when the node renderer restarts while handling an in-progress request (especially streaming requests). Workers now perform graceful shutdowns: they disconnect from the cluster to stop receiving new requests, wait for active requests to complete, then shut down cleanly. A configurable `gracefulWorkerRestartTimeout` ensures workers are forcibly killed if they don't shut down in time. [PR 1970](https://github.com/shakacode/react_on_rails/pull/1970) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
51
+
52
+ - **Body Duplication Bug On Streaming**: Fixed a bug that happens while streaming if the node renderer connection closed after streaming some chunks to the client. [PR 1995](https://github.com/shakacode/react_on_rails/pull/1995) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
53
+
49
54
  ### Changed
50
55
 
51
- - Renamed Node Renderer configuration option `bundlePath` to `serverBundleCachePath` to better clarify its purpose as a cache directory for uploaded server bundles, distinct from Shakapacker's public asset directory. The old `bundlePath` property and `RENDERER_BUNDLE_PATH` environment variable continue to work with deprecation warnings. [PR 2008](https://github.com/shakacode/react_on_rails/pull/2008) by [justin808](https://github.com/justin808).
56
+ ### Deprecated
57
+
58
+ - **Node Renderer Configuration**: Renamed `bundlePath` configuration option to `serverBundleCachePath` in the node renderer to better describe its purpose and avoid confusion with Shakapacker's public bundle path. The old `bundlePath` option continues to work with deprecation warnings. Both `RENDERER_SERVER_BUNDLE_CACHE_PATH` (new) and `RENDERER_BUNDLE_PATH` (deprecated) environment variables are supported. [PR 2008](https://github.com/shakacode/react_on_rails/pull/2008) by [justin808](https://github.com/justin808).
52
59
 
53
60
  ### Changed (Breaking)
54
61
 
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (16.2.0.beta.10)
12
+ react_on_rails (16.2.0.beta.12)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,7 +20,7 @@ PATH
20
20
  PATH
21
21
  remote: .
22
22
  specs:
23
- react_on_rails_pro (16.2.0.beta.10)
23
+ react_on_rails_pro (16.2.0.beta.12)
24
24
  addressable
25
25
  async (>= 2.6)
26
26
  connection_pool
@@ -28,7 +28,7 @@ PATH
28
28
  httpx (~> 1.5)
29
29
  jwt (~> 2.7)
30
30
  rainbow
31
- react_on_rails (= 16.2.0.beta.10)
31
+ react_on_rails (= 16.2.0.beta.12)
32
32
 
33
33
  GEM
34
34
  remote: https://rubygems.org/
@@ -347,7 +347,8 @@ module ReactOnRailsProHelper
347
347
  render_options: render_options
348
348
  )
349
349
  else
350
- result_console_script = render_options.replay_console ? chunk_json_result["consoleReplayScript"] : ""
350
+ console_script = chunk_json_result["consoleReplayScript"]
351
+ result_console_script = render_options.replay_console ? wrap_console_script_with_nonce(console_script) : ""
351
352
  # No need to prepend component_specification_tag or add rails context again
352
353
  # as they're already included in the first chunk
353
354
  compose_react_component_html_with_spec_and_console(
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactOnRailsPro
4
- VERSION = "16.2.0.beta.10"
4
+ VERSION = "16.2.0.beta.12"
5
5
  PROTOCOL_VERSION = "2.0.0"
6
6
  end
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails-pro-node-renderer",
3
- "version": "16.2.0-beta.10",
3
+ "version": "16.2.0-beta.12",
4
4
  "protocolVersion": "2.0.0",
5
5
  "description": "react-on-rails-pro JavaScript for react_on_rails_pro Ruby gem",
6
6
  "exports": {
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ../../..
11
11
  specs:
12
- react_on_rails (16.2.0.beta.10)
12
+ react_on_rails (16.2.0.beta.12)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,7 +20,7 @@ PATH
20
20
  PATH
21
21
  remote: ../..
22
22
  specs:
23
- react_on_rails_pro (16.2.0.beta.10)
23
+ react_on_rails_pro (16.2.0.beta.12)
24
24
  addressable
25
25
  async (>= 2.6)
26
26
  connection_pool
@@ -28,7 +28,7 @@ PATH
28
28
  httpx (~> 1.5)
29
29
  jwt (~> 2.7)
30
30
  rainbow
31
- react_on_rails (= 16.2.0.beta.10)
31
+ react_on_rails (= 16.2.0.beta.12)
32
32
 
33
33
  GEM
34
34
  remote: https://rubygems.org/
@@ -295,13 +295,13 @@ describe ReactOnRailsProHelper do
295
295
  let(:chunks) do
296
296
  [
297
297
  { html: "<div>Chunk 1: Stream React Server Components</div>",
298
- consoleReplayScript: "<script>console.log.apply(console, " \
299
- "['Chunk 1: Console Message'])</script>" },
298
+ consoleReplayScript: "console.log.apply(console, " \
299
+ "['Chunk 1: Console Message'])" },
300
300
  { html: "<div>Chunk 2: More content</div>",
301
- consoleReplayScript: "<script>console.log.apply(console, " \
301
+ consoleReplayScript: "console.log.apply(console, " \
302
302
  "['Chunk 2: Console Message']);\n" \
303
303
  "console.error.apply(console, " \
304
- "['Chunk 2: Console Error']);</script>" },
304
+ "['Chunk 2: Console Error']);" },
305
305
  { html: "<div>Chunk 3: Final content</div>", consoleReplayScript: "" }
306
306
  ]
307
307
  end
@@ -373,7 +373,12 @@ describe ReactOnRailsProHelper do
373
373
  mock_request_and_response
374
374
  initial_result = stream_react_component(component_name, props: props, **component_options)
375
375
  expect(initial_result).to include(react_component_div_with_initial_chunk)
376
- expect(initial_result).to include(chunks.first[:consoleReplayScript])
376
+ # consoleReplayScript is now wrapped in a script tag with id="consoleReplayLog"
377
+ if chunks.first[:consoleReplayScript].present?
378
+ script = chunks.first[:consoleReplayScript]
379
+ wrapped = "<script id=\"consoleReplayLog\">#{script}</script>"
380
+ expect(initial_result).to include(wrapped)
381
+ end
377
382
  expect(initial_result).not_to include("More content", "Final content")
378
383
  expect(chunks_read.count).to eq(1)
379
384
  end
@@ -386,9 +391,12 @@ describe ReactOnRailsProHelper do
386
391
  expect(fiber).to be_alive
387
392
 
388
393
  second_result = fiber.resume
389
- # regex that matches the html and consoleReplayScript and allows for any amount of whitespace between them
394
+ # regex that matches the html and wrapped consoleReplayScript
395
+ # Note: consoleReplayScript is now wrapped in a script tag with id="consoleReplayLog"
396
+ script = chunks[1][:consoleReplayScript]
397
+ wrapped = script.present? ? "<script id=\"consoleReplayLog\">#{script}</script>" : ""
390
398
  expect(second_result).to match(
391
- /#{Regexp.escape(chunks[1][:html])}\s+#{Regexp.escape(chunks[1][:consoleReplayScript])}/
399
+ /#{Regexp.escape(chunks[1][:html])}\s+#{Regexp.escape(wrapped)}/
392
400
  )
393
401
  expect(second_result).not_to include("Stream React Server Components", "Final content")
394
402
  expect(chunks_read.count).to eq(2)
@@ -21,15 +21,20 @@ describe "Console logging from server" do
21
21
  console.log.apply(console, ["[SERVER] Script4\\"</div>\\"(/script <script>alert('WTF4')(/script>"]);
22
22
  console.log.apply(console, ["[SERVER] Script5:\\"</div>\\"(/script> <script>alert('WTF5')(/script>"]);
23
23
  console.log.apply(console, ["[SERVER] railsContext.serverSide is ","true"]);
24
+ console.log.apply(console, ["[SERVER] RENDERED ReduxSharedStoreApp to dom node with id: ReduxSharedStoreApp-react-component-1"]);
24
25
  JS
25
26
 
26
27
  expected_lines = expected.split("\n")
27
28
 
28
- script_node = html_nodes.css("script#consoleReplayLog")
29
- script_lines = script_node.text.split("\n")
29
+ # When multiple components with replay_console are rendered, each creates its own script tag
30
+ # with id="consoleReplayLog". Nokogiri's .text concatenates them without separators, which
31
+ # breaks parsing. Instead, we explicitly join them with newlines.
32
+ script_nodes = html_nodes.css("script#consoleReplayLog")
33
+ script_text = script_nodes.map(&:text).join("\n")
34
+ script_lines = script_text.split("\n")
30
35
 
31
- # First item is a blank line since expected script starts form "\n":
32
- script_lines.shift
36
+ # Remove leading blank line if present (old format had it, new format doesn't)
37
+ script_lines.shift if script_lines.first && script_lines.first.empty?
33
38
 
34
39
  # Create external iterators for expected and found console replay script lines:
35
40
  expected_lines_iterator = expected_lines.to_enum
@@ -0,0 +1,15 @@
1
+ module ReactOnRails
2
+ module Dev
3
+ class FileManager
4
+ def self.cleanup_stale_files: () -> bool
5
+
6
+ private
7
+
8
+ def self.cleanup_overmind_sockets: () -> bool
9
+ def self.cleanup_rails_pid_file: () -> bool
10
+ def self.overmind_running?: () -> bool
11
+ def self.process_running?: (Integer) -> bool
12
+ def self.remove_file_if_exists: (String, String) -> bool
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ module ReactOnRails
2
+ module Dev
3
+ class PackGenerator
4
+ def self.generate: (?verbose: bool) -> void
5
+
6
+ private
7
+
8
+ def self.run_pack_generation: (?silent: bool, ?verbose: bool) -> bool
9
+ def self.should_run_directly?: () -> bool
10
+ def self.rails_available?: () -> bool
11
+ def self.run_rake_task_directly: (?silent: bool) -> bool
12
+ def self.load_rake_tasks: () -> void
13
+ def self.prepare_rake_task: () -> untyped
14
+ def self.capture_output: (bool) { () -> bool } -> bool
15
+ def self.handle_rake_error: (Exception, bool) -> void
16
+ def self.run_via_bundle_exec: (?silent: bool, ?verbose: bool) -> (bool | nil)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ module ReactOnRails
2
+ module Dev
3
+ class ProcessManager
4
+ VERSION_CHECK_TIMEOUT: Integer
5
+
6
+ def self.installed?: (String) -> bool
7
+ def self.ensure_procfile: (String) -> void
8
+ def self.run_with_process_manager: (String) -> void
9
+
10
+ private
11
+
12
+ def self.installed_in_current_context?: (String) -> bool
13
+ def self.version_flags_for: (String) -> Array[String]
14
+ def self.run_process_if_available: (String, Array[String]) -> bool
15
+ def self.run_process_outside_bundle: (String, Array[String]) -> bool
16
+ def self.process_available_in_system?: (String) -> bool
17
+ def self.with_unbundled_context: () { () -> untyped } -> untyped
18
+ def self.show_process_manager_installation_help: () -> void
19
+ def self.valid_procfile_path?: (String) -> bool
20
+ end
21
+ end
22
+ end