react_on_rails 16.1.2 → 16.2.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +85 -0
  4. data/Gemfile.development_dependencies +8 -7
  5. data/Gemfile.lock +158 -119
  6. data/Steepfile +56 -0
  7. data/lib/generators/react_on_rails/base_generator.rb +43 -120
  8. data/lib/generators/react_on_rails/dev_tests_generator.rb +2 -1
  9. data/lib/generators/react_on_rails/generator_helper.rb +102 -2
  10. data/lib/generators/react_on_rails/install_generator.rb +36 -156
  11. data/lib/generators/react_on_rails/js_dependency_manager.rb +383 -0
  12. data/lib/generators/react_on_rails/templates/base/base/.dev-services.yml.example +76 -0
  13. data/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-precompile-hook +30 -0
  14. data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +141 -0
  15. data/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt +44 -45
  16. data/lib/generators/react_on_rails/templates/base/base/config/{shakapacker.yml → shakapacker.yml.tt} +28 -3
  17. data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +15 -9
  18. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +42 -6
  19. data/lib/react_on_rails/configuration.rb +149 -32
  20. data/lib/react_on_rails/controller.rb +3 -3
  21. data/lib/react_on_rails/dev/pack_generator.rb +168 -2
  22. data/lib/react_on_rails/dev/process_manager.rb +136 -14
  23. data/lib/react_on_rails/dev/server_manager.rb +194 -26
  24. data/lib/react_on_rails/dev/service_checker.rb +200 -0
  25. data/lib/react_on_rails/doctor.rb +341 -12
  26. data/lib/react_on_rails/engine.rb +75 -1
  27. data/lib/react_on_rails/git_utils.rb +3 -1
  28. data/lib/react_on_rails/helper.rb +70 -192
  29. data/lib/react_on_rails/locales/base.rb +17 -5
  30. data/lib/react_on_rails/packer_utils.rb +79 -2
  31. data/lib/react_on_rails/packs_generator.rb +57 -39
  32. data/lib/react_on_rails/prerender_error.rb +74 -17
  33. data/lib/react_on_rails/pro_helper.rb +64 -0
  34. data/lib/react_on_rails/react_component/render_options.rb +7 -7
  35. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +2 -5
  36. data/lib/react_on_rails/smart_error.rb +326 -0
  37. data/lib/react_on_rails/system_checker.rb +8 -9
  38. data/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb +16 -7
  39. data/lib/react_on_rails/utils.rb +241 -55
  40. data/lib/react_on_rails/version.rb +1 -1
  41. data/lib/react_on_rails/version_checker.rb +383 -35
  42. data/lib/tasks/generate_packs.rake +12 -6
  43. data/lib/tasks/locale.rake +6 -1
  44. data/rakelib/docker.rake +26 -0
  45. data/rakelib/dummy_apps.rake +30 -0
  46. data/rakelib/example_type.rb +121 -0
  47. data/rakelib/examples_config.yml +52 -0
  48. data/rakelib/lint.rake +52 -0
  49. data/rakelib/node_package.rake +15 -0
  50. data/rakelib/rbs.rake +70 -0
  51. data/rakelib/run_rspec.rake +223 -0
  52. data/rakelib/shakapacker_examples.rake +171 -0
  53. data/rakelib/task_helpers.rb +134 -0
  54. data/rakelib/update_changelog.rake +73 -0
  55. data/react_on_rails.gemspec +4 -3
  56. data/sig/README.md +52 -0
  57. data/sig/react_on_rails/configuration.rbs +96 -0
  58. data/sig/react_on_rails/controller.rbs +15 -0
  59. data/sig/react_on_rails/dev/file_manager.rbs +15 -0
  60. data/sig/react_on_rails/dev/pack_generator.rbs +19 -0
  61. data/sig/react_on_rails/dev/process_manager.rbs +22 -0
  62. data/sig/react_on_rails/dev/server_manager.rbs +39 -0
  63. data/sig/react_on_rails/dev/service_checker.rbs +22 -0
  64. data/sig/react_on_rails/error.rbs +4 -0
  65. data/sig/react_on_rails/generators/js_dependency_manager.rbs +123 -0
  66. data/sig/react_on_rails/git_utils.rbs +8 -0
  67. data/sig/react_on_rails/helper.rbs +65 -0
  68. data/sig/react_on_rails/json_parse_error.rbs +10 -0
  69. data/sig/react_on_rails/locales.rbs +46 -0
  70. data/sig/react_on_rails/packer_utils.rbs +15 -0
  71. data/sig/react_on_rails/prerender_error.rbs +21 -0
  72. data/sig/react_on_rails/server_rendering_pool.rbs +12 -0
  73. data/sig/react_on_rails/smart_error.rbs +28 -0
  74. data/sig/react_on_rails/test_helper.rbs +11 -0
  75. data/sig/react_on_rails/utils.rbs +34 -0
  76. data/sig/react_on_rails/version_checker.rbs +12 -0
  77. data/sig/react_on_rails.rbs +17 -0
  78. metadata +49 -32
  79. data/AI_AGENT_INSTRUCTIONS.md +0 -63
  80. data/CHANGELOG.md +0 -1836
  81. data/CLAUDE.md +0 -135
  82. data/CODING_AGENTS.md +0 -313
  83. data/CONTRIBUTING.md +0 -668
  84. data/Dockerfile_tests +0 -12
  85. data/KUDOS.md +0 -114
  86. data/LICENSE.md +0 -47
  87. data/LICENSES/README.md +0 -14
  88. data/NEWS.md +0 -62
  89. data/PROJECTS.md +0 -63
  90. data/REACT-ON-RAILS-PRO-LICENSE.md +0 -129
  91. data/README.md +0 -217
  92. data/SUMMARY.md +0 -88
  93. data/TODO.md +0 -135
  94. data/bin/lefthook/check-trailing-newlines +0 -38
  95. data/bin/lefthook/get-changed-files +0 -26
  96. data/bin/lefthook/prettier-format +0 -26
  97. data/bin/lefthook/ruby-autofix +0 -26
  98. data/bin/lefthook/ruby-lint +0 -27
  99. data/docker-compose.yml +0 -11
  100. data/eslint.config.ts +0 -232
  101. data/knip.ts +0 -114
  102. data/lib/react_on_rails/pro/NOTICE +0 -21
  103. data/lib/react_on_rails/pro/helper.rb +0 -122
  104. data/lib/react_on_rails/pro/utils.rb +0 -53
  105. data/tsconfig.eslint.json +0 -6
  106. data/tsconfig.json +0 -19
@@ -5,6 +5,7 @@ require "open3"
5
5
  require "rainbow"
6
6
  require "active_support"
7
7
  require "active_support/core_ext/string"
8
+ require "shellwords"
8
9
 
9
10
  # rubocop:disable Metrics/ModuleLength
10
11
  module ReactOnRails
@@ -13,6 +14,46 @@ module ReactOnRails
13
14
  Rainbow('To see the full output, set FULL_TEXT_ERRORS=true.').red
14
15
  } ...\n".freeze
15
16
 
17
+ def self.immediate_hydration_pro_license_warning(name, type = "Component")
18
+ "[REACT ON RAILS] Warning: immediate_hydration: true requires a React on Rails Pro license.\n" \
19
+ "#{type} '#{name}' will fall back to standard hydration behavior.\n" \
20
+ "Visit https://www.shakacode.com/react-on-rails-pro/ for licensing information."
21
+ end
22
+
23
+ # Normalizes the immediate_hydration option value, enforcing Pro license requirements.
24
+ # Returns the normalized boolean value for immediate_hydration.
25
+ #
26
+ # @param value [Boolean, nil] The immediate_hydration option value
27
+ # @param name [String] The name of the component/store (for warning messages)
28
+ # @param type [String] The type ("Component" or "Store") for warning messages
29
+ # @return [Boolean] The normalized immediate_hydration value
30
+ # @raise [ArgumentError] If value is not a boolean or nil
31
+ #
32
+ # Logic:
33
+ # - Validates that value is true, false, or nil
34
+ # - If value is explicitly true (boolean) and no Pro license: warn and return false
35
+ # - If value is nil: return true for Pro users, false for non-Pro users
36
+ # - Otherwise: return the value as-is (allows explicit false to work)
37
+ def self.normalize_immediate_hydration(value, name, type = "Component")
38
+ # Type validation: only accept boolean or nil
39
+ unless [true, false, nil].include?(value)
40
+ raise ArgumentError,
41
+ "[REACT ON RAILS] immediate_hydration must be true, false, or nil. Got: #{value.inspect} (#{value.class})"
42
+ end
43
+
44
+ # Strict equality check: only trigger warning for explicit boolean true
45
+ if value == true && !react_on_rails_pro?
46
+ Rails.logger.warn immediate_hydration_pro_license_warning(name, type)
47
+ return false
48
+ end
49
+
50
+ # If nil, default based on Pro license status
51
+ return react_on_rails_pro? if value.nil?
52
+
53
+ # Return explicit value (including false)
54
+ value
55
+ end
56
+
16
57
  # https://forum.shakacode.com/t/yak-of-the-week-ruby-2-4-pathname-empty-changed-to-look-at-file-size/901
17
58
  # return object if truthy, else return nil
18
59
  def self.truthy_presence(obj)
@@ -57,9 +98,12 @@ module ReactOnRails
57
98
  exitstatus: #{status.exitstatus}#{stdout_msg}#{stderr_msg}
58
99
  MSG
59
100
 
60
- puts wrap_message(msg)
61
- puts ""
62
- puts default_troubleshooting_section
101
+ # Use warn to ensure output is visible in CI logs (goes to stderr)
102
+ # and flush immediately before calling exit!
103
+ warn wrap_message(msg)
104
+ warn ""
105
+ warn default_troubleshooting_section
106
+ $stderr.flush
63
107
 
64
108
  # Rspec catches exit without! in the exit callbacks
65
109
  exit!(1)
@@ -111,9 +155,16 @@ module ReactOnRails
111
155
 
112
156
  private_class_method def self.server_bundle?(bundle_name)
113
157
  config = ReactOnRails.configuration
114
- bundle_name == config.server_bundle_js_file ||
115
- bundle_name == config.rsc_bundle_js_file ||
116
- bundle_name == config.react_server_client_manifest_file
158
+ return true if bundle_name == config.server_bundle_js_file
159
+
160
+ # Check Pro configurations if Pro is available
161
+ if react_on_rails_pro?
162
+ pro_config = ReactOnRailsPro.configuration
163
+ return true if bundle_name == pro_config.rsc_bundle_js_file ||
164
+ bundle_name == pro_config.react_server_client_manifest_file
165
+ end
166
+
167
+ false
117
168
  end
118
169
 
119
170
  private_class_method def self.handle_missing_manifest_entry(bundle_name, is_server_bundle)
@@ -146,34 +197,6 @@ module ReactOnRails
146
197
  @server_bundle_path = bundle_js_file_path(bundle_name)
147
198
  end
148
199
 
149
- def self.rsc_bundle_js_file_path
150
- return @rsc_bundle_path if @rsc_bundle_path && !Rails.env.development?
151
-
152
- bundle_name = ReactOnRails.configuration.rsc_bundle_js_file
153
- @rsc_bundle_path = bundle_js_file_path(bundle_name)
154
- end
155
-
156
- def self.react_client_manifest_file_path
157
- return @react_client_manifest_path if @react_client_manifest_path && !Rails.env.development?
158
-
159
- file_name = ReactOnRails.configuration.react_client_manifest_file
160
- @react_client_manifest_path = ReactOnRails::PackerUtils.asset_uri_from_packer(file_name)
161
- end
162
-
163
- # React Server Manifest is generated by the server bundle.
164
- # So, it will never be served from the dev server.
165
- def self.react_server_client_manifest_file_path
166
- return @react_server_manifest_path if @react_server_manifest_path && !Rails.env.development?
167
-
168
- asset_name = ReactOnRails.configuration.react_server_client_manifest_file
169
- if asset_name.nil?
170
- raise ReactOnRails::Error,
171
- "react_server_client_manifest_file is nil, ensure it is set in your configuration"
172
- end
173
-
174
- @react_server_manifest_path = bundle_js_file_path(asset_name)
175
- end
176
-
177
200
  def self.running_on_windows?
178
201
  (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
179
202
  end
@@ -228,11 +251,19 @@ module ReactOnRails
228
251
  end
229
252
  end
230
253
 
231
- # Todo -- remove this for v13, as we don't need both boolean and number
254
+ # Checks if React on Rails Pro is installed and licensed.
255
+ # This method validates the license and will raise an exception if invalid.
256
+ #
257
+ # @return [Boolean] true if Pro is available with valid license
258
+ # @raise [ReactOnRailsPro::Error] if license is invalid
232
259
  def self.react_on_rails_pro?
233
260
  return @react_on_rails_pro if defined?(@react_on_rails_pro)
234
261
 
235
- @react_on_rails_pro = gem_available?("react_on_rails_pro")
262
+ @react_on_rails_pro = begin
263
+ return false unless gem_available?("react_on_rails_pro")
264
+
265
+ ReactOnRailsPro::Utils.validated_license_data!.present?
266
+ end
236
267
  end
237
268
 
238
269
  # Return an empty string if React on Rails Pro is not installed
@@ -246,28 +277,12 @@ module ReactOnRails
246
277
  end
247
278
  end
248
279
 
249
- def self.react_on_rails_pro_licence_valid?
250
- return @react_on_rails_pro_licence_valid if defined?(@react_on_rails_pro_licence_valid)
251
-
252
- @react_on_rails_pro_licence_valid = begin
253
- return false unless react_on_rails_pro?
254
-
255
- # Maintain compatibility with legacy versions of React on Rails Pro:
256
- # Earlier releases did not require license validation, as they were distributed as private gems.
257
- # This check ensures that the method works correctly regardless of the installed version.
258
- return true unless ReactOnRailsPro::Utils.respond_to?(:licence_valid?)
259
-
260
- ReactOnRailsPro::Utils.licence_valid?
261
- end
262
- end
263
-
280
+ # RSC support detection has been moved to React on Rails Pro
281
+ # See react_on_rails_pro/lib/react_on_rails_pro/utils.rb
264
282
  def self.rsc_support_enabled?
265
283
  return false unless react_on_rails_pro?
266
284
 
267
- return @rsc_support_enabled if defined?(@rsc_support_enabled)
268
-
269
- rorp_config = ReactOnRailsPro.configuration
270
- @rsc_support_enabled = rorp_config.respond_to?(:enable_rsc_support) && rorp_config.enable_rsc_support
285
+ ReactOnRailsPro::Utils.rsc_support_enabled?
271
286
  end
272
287
 
273
288
  def self.full_text_errors_enabled?
@@ -312,6 +327,177 @@ module ReactOnRails
312
327
  puts "Prepended\n#{text_to_prepend}to #{file}."
313
328
  end
314
329
 
330
+ # Detects which package manager is being used.
331
+ # First checks the packageManager field in package.json (Node.js Corepack standard),
332
+ # then falls back to checking for lock files.
333
+ #
334
+ # @return [Symbol] The package manager symbol (:npm, :yarn, :pnpm, :bun)
335
+ def self.detect_package_manager
336
+ manager = detect_package_manager_from_package_json || detect_package_manager_from_lock_files
337
+ manager || :yarn # Default to yarn if no detection succeeds
338
+ end
339
+
340
+ # Validates package_name input to prevent command injection
341
+ #
342
+ # @param package_name [String] The package name to validate
343
+ # @raise [ReactOnRails::Error] if package_name contains potentially unsafe characters
344
+ private_class_method def self.validate_package_name!(package_name)
345
+ raise ReactOnRails::Error, "package_name cannot be nil" if package_name.nil?
346
+ raise ReactOnRails::Error, "package_name cannot be empty" if package_name.to_s.strip.empty?
347
+
348
+ # Allow valid npm package names: alphanumeric, hyphens, underscores, dots, slashes (for scoped packages)
349
+ # See: https://github.com/npm/validate-npm-package-name
350
+ return if package_name.match?(%r{\A[@a-z0-9][a-z0-9._/-]*\z}i)
351
+
352
+ raise ReactOnRails::Error, "Invalid package name: #{package_name.inspect}. " \
353
+ "Package names must contain only alphanumeric characters, " \
354
+ "hyphens, underscores, dots, and slashes (for scoped packages)."
355
+ end
356
+
357
+ # Validates package_name and version inputs to prevent command injection
358
+ #
359
+ # @param package_name [String] The package name to validate
360
+ # @param version [String] The version to validate
361
+ # @raise [ReactOnRails::Error] if inputs contain potentially unsafe characters
362
+ private_class_method def self.validate_package_command_inputs!(package_name, version)
363
+ validate_package_name!(package_name)
364
+
365
+ raise ReactOnRails::Error, "version cannot be nil" if version.nil?
366
+ raise ReactOnRails::Error, "version cannot be empty" if version.to_s.strip.empty?
367
+
368
+ # Allow valid semver versions and common npm version patterns
369
+ # This allows: 1.2.3, 1.2.3-beta.1, 1.2.3-alpha, etc.
370
+ return if version.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
371
+
372
+ raise ReactOnRails::Error, "Invalid version: #{version.inspect}. " \
373
+ "Versions must contain only alphanumeric characters, dots, hyphens, and underscores."
374
+ end
375
+
376
+ private_class_method def self.detect_package_manager_from_package_json
377
+ package_json_path = File.join(Rails.root, ReactOnRails.configuration.node_modules_location, "package.json")
378
+ return nil unless File.exist?(package_json_path)
379
+
380
+ package_json_data = JSON.parse(File.read(package_json_path))
381
+ return nil unless package_json_data["packageManager"]
382
+
383
+ manager_string = package_json_data["packageManager"]
384
+ # Extract manager name from strings like "yarn@3.6.0" or "pnpm@8.0.0"
385
+ manager_name = manager_string.split("@").first
386
+ manager_name.to_sym if %w[npm yarn pnpm bun].include?(manager_name)
387
+ rescue StandardError
388
+ nil
389
+ end
390
+
391
+ private_class_method def self.detect_package_manager_from_lock_files
392
+ root = Rails.root
393
+ return :yarn if File.exist?(File.join(root, "yarn.lock"))
394
+ return :pnpm if File.exist?(File.join(root, "pnpm-lock.yaml"))
395
+ return :bun if File.exist?(File.join(root, "bun.lockb"))
396
+ return :npm if File.exist?(File.join(root, "package-lock.json"))
397
+
398
+ nil
399
+ end
400
+
401
+ # Returns the appropriate install command for the detected package manager.
402
+ # Generates the correct command with exact version syntax.
403
+ #
404
+ # @param package_name [String] The name of the package to install
405
+ # @param version [String] The exact version to install
406
+ # @return [String] The command to run (e.g., "yarn add react-on-rails@16.0.0 --exact")
407
+ def self.package_manager_install_exact_command(package_name, version)
408
+ validate_package_command_inputs!(package_name, version)
409
+
410
+ manager = detect_package_manager
411
+ # Escape shell arguments to prevent command injection
412
+ safe_package = Shellwords.escape("#{package_name}@#{version}")
413
+
414
+ case manager
415
+ when :pnpm
416
+ "pnpm add #{safe_package} --save-exact"
417
+ when :bun
418
+ "bun add #{safe_package} --exact"
419
+ when :npm
420
+ "npm install #{safe_package} --save-exact"
421
+ else # :yarn or unknown, default to yarn
422
+ "yarn add #{safe_package} --exact"
423
+ end
424
+ end
425
+
426
+ # Returns the appropriate remove command for the detected package manager.
427
+ #
428
+ # @param package_name [String] The name of the package to remove
429
+ # @return [String] The command to run (e.g., "yarn remove react-on-rails")
430
+ def self.package_manager_remove_command(package_name)
431
+ validate_package_name!(package_name)
432
+
433
+ manager = detect_package_manager
434
+ # Escape shell arguments to prevent command injection
435
+ safe_package = Shellwords.escape(package_name)
436
+
437
+ case manager
438
+ when :pnpm
439
+ "pnpm remove #{safe_package}"
440
+ when :bun
441
+ "bun remove #{safe_package}"
442
+ when :npm
443
+ "npm uninstall #{safe_package}"
444
+ else # :yarn or unknown, default to yarn
445
+ "yarn remove #{safe_package}"
446
+ end
447
+ end
448
+
449
+ # Converts an absolute path (String or Pathname) to a path relative to Rails.root.
450
+ # If the path is already relative or doesn't contain Rails.root, returns it as-is.
451
+ #
452
+ # This method is used to normalize paths from Shakapacker's privateOutputPath (which is
453
+ # absolute) to relative paths suitable for React on Rails configuration.
454
+ #
455
+ # Note: Absolute paths that don't start with Rails.root are intentionally passed through
456
+ # unchanged. While there's no known use case for server bundles outside Rails.root,
457
+ # this behavior preserves the original path for debugging and error messages.
458
+ #
459
+ # @param path [String, Pathname] The path to normalize
460
+ # @return [String, nil] The relative path as a string, or nil if path is nil
461
+ #
462
+ # @example Converting absolute paths within Rails.root
463
+ # # Assuming Rails.root is "/app"
464
+ # normalize_to_relative_path("/app/ssr-generated") # => "ssr-generated"
465
+ # normalize_to_relative_path("/app/foo/bar") # => "foo/bar"
466
+ #
467
+ # @example Already relative paths pass through
468
+ # normalize_to_relative_path("ssr-generated") # => "ssr-generated"
469
+ # normalize_to_relative_path("./ssr-generated") # => "./ssr-generated"
470
+ #
471
+ # @example Absolute paths outside Rails.root (edge case)
472
+ # normalize_to_relative_path("/other/path/bundles") # => "/other/path/bundles"
473
+ def self.normalize_to_relative_path(path)
474
+ return nil if path.nil?
475
+
476
+ path_str = path.to_s
477
+ rails_root_str = Rails.root.to_s.chomp("/")
478
+
479
+ # Treat as "inside Rails.root" only for exact match or a subdirectory
480
+ inside_rails_root = rails_root_str.present? &&
481
+ (path_str == rails_root_str || path_str.start_with?("#{rails_root_str}/"))
482
+
483
+ # If path is within Rails.root, remove that prefix
484
+ if inside_rails_root
485
+ # Remove Rails.root and any leading slash
486
+ path_str.sub(%r{^#{Regexp.escape(rails_root_str)}/?}, "")
487
+ else
488
+ # Path is already relative or outside Rails.root
489
+ # Warn if it's an absolute path outside Rails.root (edge case)
490
+ if path_str.start_with?("/") && !inside_rails_root
491
+ Rails.logger&.warn(
492
+ "ReactOnRails: Detected absolute path outside Rails.root: '#{path_str}'. " \
493
+ "Server bundles are typically stored within Rails.root. " \
494
+ "Verify this is intentional."
495
+ )
496
+ end
497
+ path_str
498
+ end
499
+ end
500
+
315
501
  def self.default_troubleshooting_section
316
502
  <<~DEFAULT
317
503
  📞 Get Help & Support:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactOnRails
4
- VERSION = "16.1.2"
4
+ VERSION = "16.2.0"
5
5
  end