react_on_rails 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Gemfile.development_dependencies +2 -2
  4. data/Gemfile.lock +2 -14
  5. data/Rakefile +0 -6
  6. data/Steepfile +4 -0
  7. data/lib/generators/react_on_rails/base_generator.rb +4 -4
  8. data/lib/generators/react_on_rails/demo_page_config.rb +3 -3
  9. data/lib/generators/react_on_rails/dev_tests_generator.rb +1 -1
  10. data/lib/generators/react_on_rails/generator_helper.rb +6 -65
  11. data/lib/generators/react_on_rails/generator_messages/ci_section.rb +42 -0
  12. data/lib/generators/react_on_rails/generator_messages/package_manager_detection.rb +194 -0
  13. data/lib/generators/react_on_rails/generator_messages/shakapacker_status_section.rb +61 -0
  14. data/lib/generators/react_on_rails/generator_messages.rb +22 -79
  15. data/lib/generators/react_on_rails/install_generator.rb +243 -28
  16. data/lib/generators/react_on_rails/js_dependency_manager.rb +7 -4
  17. data/lib/generators/react_on_rails/pro/USAGE +1 -1
  18. data/lib/generators/react_on_rails/pro_generator.rb +206 -183
  19. data/lib/generators/react_on_rails/pro_setup.rb +102 -26
  20. data/lib/generators/react_on_rails/react_with_redux_generator.rb +3 -2
  21. data/lib/generators/react_on_rails/templates/base/base/.env.example +25 -0
  22. data/lib/generators/react_on_rails/templates/base/base/.github/workflows/ci.yml.tt +86 -0
  23. data/lib/generators/react_on_rails/templates/base/base/Procfile.dev +4 -3
  24. data/lib/generators/react_on_rails/templates/base/base/babel.config.js.tt +1 -1
  25. data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +2 -2
  26. data/lib/generators/react_on_rails/templates/base/base/config/webpack/ServerClientOrBoth.js.tt +1 -1
  27. data/lib/generators/react_on_rails/templates/base/base/config/webpack/clientWebpackConfig.js.tt +1 -1
  28. data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +2 -2
  29. data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +1 -1
  30. data/lib/generators/react_on_rails/templates/base/base/config/webpack/production.js.tt +1 -1
  31. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +6 -5
  32. data/lib/generators/react_on_rails/templates/base/base/config/webpack/test.js.tt +1 -1
  33. data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
  34. data/lib/generators/react_on_rails/templates/pro/base/{client → renderer}/node-renderer.js +1 -0
  35. data/lib/react_on_rails/config_path_resolver.rb +101 -4
  36. data/lib/react_on_rails/configuration.rb +22 -0
  37. data/lib/react_on_rails/dev/file_manager.rb +135 -8
  38. data/lib/react_on_rails/dev/port_selector.rb +259 -7
  39. data/lib/react_on_rails/dev/process_manager.rb +29 -2
  40. data/lib/react_on_rails/dev/server_manager.rb +607 -39
  41. data/lib/react_on_rails/doctor.rb +513 -45
  42. data/lib/react_on_rails/helper.rb +3 -11
  43. data/lib/react_on_rails/js_code_builder.rb +66 -0
  44. data/lib/react_on_rails/length_prefixed_parser.rb +142 -0
  45. data/lib/react_on_rails/packs_generator.rb +65 -12
  46. data/lib/react_on_rails/pro_migration.rb +175 -0
  47. data/lib/react_on_rails/render_request.rb +74 -0
  48. data/lib/react_on_rails/rendering_strategy/exec_js_strategy.rb +29 -0
  49. data/lib/react_on_rails/rendering_strategy.rb +44 -0
  50. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +33 -22
  51. data/lib/react_on_rails/system_checker.rb +44 -23
  52. data/lib/react_on_rails/utils.rb +5 -0
  53. data/lib/react_on_rails/version.rb +1 -1
  54. data/lib/react_on_rails.rb +3 -0
  55. data/rakelib/run_rspec.rake +0 -5
  56. data/rakelib/shakapacker_examples.rake +66 -23
  57. data/react_on_rails.gemspec +18 -8
  58. data/sig/react_on_rails/js_code_builder.rbs +11 -0
  59. data/sig/react_on_rails/render_request.rbs +28 -0
  60. data/sig/react_on_rails/rendering_strategy/exec_js_strategy.rbs +11 -0
  61. data/sig/react_on_rails/rendering_strategy.rbs +7 -0
  62. data/sig/react_on_rails.rbs +6 -0
  63. metadata +31 -10
@@ -3,12 +3,14 @@
3
3
  require "json"
4
4
  require "erb"
5
5
  require "stringio"
6
+ require "timeout"
6
7
  require "yaml"
7
8
  require_relative "utils"
8
9
  require_relative "config_path_resolver"
9
10
  require_relative "version_syntax_converter"
10
11
  require_relative "version_synchronizer"
11
12
  require_relative "system_checker"
13
+ require_relative "pro_migration"
12
14
 
13
15
  begin
14
16
  require "rainbow"
@@ -55,6 +57,59 @@ module ReactOnRails
55
57
  MINITEST_HELPER_FILE = "test/test_helper.rb"
56
58
  DEFAULT_BUILD_TEST_COMMAND = 'config.build_test_command = "RAILS_ENV=test bin/shakapacker"'
57
59
  DEFAULT_SHAKAPACKER_CONFIG_PATH = "config/shakapacker.yml"
60
+ SERVER_BUNDLE_SOURCE_EXTENSIONS = %w[.js .jsx .ts .tsx .mjs .cjs].freeze
61
+ CUSTOM_LAUNCHER_INDICATOR_FILES = %w[dev].freeze
62
+
63
+ # Deprecated-renderer-cache scan (used by check_deprecated_renderer_cache_task):
64
+ # look for references to the old pre_stage_bundle_for_node_renderer task in
65
+ # common deploy-script locations so users on older Procfile/Dockerfile entries
66
+ # get a migration nudge before the task is removed.
67
+ DEPRECATED_RENDERER_CACHE_TASK = "pre_stage_bundle_for_node_renderer"
68
+ # Fixed allowlist of single-file deploy-script paths. Each entry is a literal
69
+ # path that may host a deploy hook referencing the deprecated task. Directory
70
+ # globs (e.g., per-stage Capistrano files or per-workflow GitHub Actions YAML)
71
+ # live in RENDERER_CACHE_DEPLOY_SCRIPT_GLOBS so they stay bounded.
72
+ RENDERER_CACHE_DEPLOY_SCRIPT_PATHS = [
73
+ "Procfile",
74
+ "Procfile.dev",
75
+ "Procfile.dev-static-assets",
76
+ "Procfile.production",
77
+ "Dockerfile",
78
+ "Dockerfile.production",
79
+ "Dockerfile.staging",
80
+ "Dockerfile.review",
81
+ "docker-compose.yml",
82
+ "docker-compose.yaml",
83
+ "compose.yml",
84
+ "compose.yaml",
85
+ "bin/deploy",
86
+ "bin/release",
87
+ "bin/docker-entrypoint",
88
+ "config/deploy.rb",
89
+ "config/deploy/production.rb",
90
+ "config/deploy/staging.rb",
91
+ ".kamal/deploy.yml",
92
+ "scripts/deploy.sh",
93
+ ".circleci/config.yml",
94
+ ".gitlab-ci.yml",
95
+ "bitbucket-pipelines.yml"
96
+ ].freeze
97
+ # Bounded glob allowlist for deploy manifests that live in a known directory
98
+ # but use per-environment or per-workflow filenames. Each pattern matches
99
+ # only one directory level (no `**`) so the scan never recurses into the
100
+ # project tree, and the expansion is capped by
101
+ # RENDERER_CACHE_DEPLOY_SCRIPT_GLOB_MAX_MATCHES.
102
+ RENDERER_CACHE_DEPLOY_SCRIPT_GLOBS = [
103
+ ".github/workflows/*.yml",
104
+ ".github/workflows/*.yaml",
105
+ "config/deploy/*.rb"
106
+ ].freeze
107
+ # Per-file safety gate to bound IO during the scan, not a meaningful size limit.
108
+ RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES = 1_048_576
109
+ # Defense-in-depth cap on how many files a single glob may contribute.
110
+ # Realistic repos have a handful of workflow / deploy-stage files; far more
111
+ # than this is a sign of an unexpectedly broad pattern, not legitimate config.
112
+ RENDERER_CACHE_DEPLOY_SCRIPT_GLOB_MAX_MATCHES = 100
58
113
 
59
114
  def initialize(verbose: false, fix: false)
60
115
  @verbose = verbose
@@ -180,7 +235,7 @@ module ReactOnRails
180
235
 
181
236
  def check_bin_dev_launcher
182
237
  checker.add_info("🚀 bin/dev Launcher:")
183
- check_bin_dev_launcher_setup
238
+ return unless check_bin_dev_launcher_setup
184
239
 
185
240
  checker.add_info("\n📄 Launcher Procfiles:")
186
241
  check_launcher_procfiles
@@ -489,8 +544,8 @@ module ReactOnRails
489
544
  end
490
545
 
491
546
  def check_npm_package_version
492
- package_json_path = resolved_package_json_path
493
- return unless File.exist?(package_json_path)
547
+ package_json_path = package_json_path_for("react-on-rails npm package version")
548
+ return unless package_json_path
494
549
 
495
550
  begin
496
551
  package_json = JSON.parse(File.read(package_json_path))
@@ -605,8 +660,8 @@ module ReactOnRails
605
660
  end
606
661
 
607
662
  def check_npm_wildcards
608
- package_json_path = resolved_package_json_path
609
- return unless File.exist?(package_json_path)
663
+ package_json_path = package_json_path_for("npm package version constraints")
664
+ return unless package_json_path
610
665
 
611
666
  begin
612
667
  package_json = JSON.parse(File.read(package_json_path))
@@ -719,8 +774,8 @@ module ReactOnRails
719
774
  end
720
775
 
721
776
  def auto_fix_versions
722
- package_json_path = resolved_package_json_path
723
- return unless File.exist?(package_json_path)
777
+ package_json_path = package_json_path_for("package version auto-sync")
778
+ return unless package_json_path
724
779
 
725
780
  synchronizer = ReactOnRails::VersionSynchronizer.new(package_json_path: package_json_path, io: StringIO.new)
726
781
  result = synchronizer.sync(write: true)
@@ -764,8 +819,8 @@ module ReactOnRails
764
819
  end
765
820
 
766
821
  def check_pro_package_consistency
767
- package_json_path = resolved_package_json_path
768
- return unless File.exist?(package_json_path)
822
+ package_json_path = package_json_path_for("Pro package consistency")
823
+ return unless package_json_path
769
824
 
770
825
  package_json = JSON.parse(File.read(package_json_path))
771
826
  all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
@@ -1423,12 +1478,24 @@ module ReactOnRails
1423
1478
  checker.add_info(" • Modern React patterns recommended")
1424
1479
  end
1425
1480
 
1481
+ # Returns true if bin/dev exists, false otherwise.
1482
+ # Used by check_bin_dev_launcher to decide whether to check Procfiles.
1426
1483
  def check_bin_dev_launcher_setup
1427
1484
  bin_dev_path = "bin/dev"
1428
1485
 
1429
1486
  unless File.exist?(bin_dev_path)
1430
- checker.add_error(" 🚫 bin/dev script not found")
1431
- return
1487
+ checker.add_warning(" ⚠️ Official React on Rails bin/dev launcher not found")
1488
+ custom_launchers = detected_custom_launcher_paths
1489
+ if custom_launchers.any?
1490
+ checker.add_info(
1491
+ " ℹ️ Custom launcher detected (#{custom_launchers.join(', ')}). " \
1492
+ "This is OK if your project intentionally manages its own dev workflow."
1493
+ )
1494
+ checker.add_info(" 💡 To use the official launcher instead, run: rails generate react_on_rails:install")
1495
+ else
1496
+ checker.add_info(" 💡 Generate the official launcher with: rails generate react_on_rails:install")
1497
+ end
1498
+ return false
1432
1499
  end
1433
1500
 
1434
1501
  content = File.read(bin_dev_path)
@@ -1441,6 +1508,8 @@ module ReactOnRails
1441
1508
  checker.add_warning(" ⚠️ bin/dev exists but doesn't use ReactOnRails Launcher")
1442
1509
  checker.add_info(" 💡 Consider upgrading: rails generate react_on_rails:install")
1443
1510
  end
1511
+
1512
+ true
1444
1513
  end
1445
1514
 
1446
1515
  def check_launcher_procfiles
@@ -1468,6 +1537,14 @@ module ReactOnRails
1468
1537
  end
1469
1538
  end
1470
1539
 
1540
+ def detected_custom_launcher_paths
1541
+ CUSTOM_LAUNCHER_INDICATOR_FILES.filter_map do |path|
1542
+ next unless File.file?(path)
1543
+
1544
+ path == "dev" ? "./dev" : path
1545
+ end
1546
+ end
1547
+
1471
1548
  def check_test_helper_setup
1472
1549
  framework_status = test_helper_status_by_framework
1473
1550
 
@@ -1535,11 +1612,12 @@ module ReactOnRails
1535
1612
  source_entry_path = source_entry_path.sub("#{source_path}/", "")
1536
1613
  end
1537
1614
 
1538
- File.join(source_path, source_entry_path, bundle_filename)
1615
+ bundle_path = File.join(source_path, source_entry_path, bundle_filename)
1616
+ resolve_server_bundle_source_path(bundle_path)
1539
1617
  rescue LoadError, StandardError
1540
1618
  # Handle missing Shakapacker gem or other configuration errors
1541
1619
  bundle_filename = server_bundle_filename
1542
- "app/javascript/packs/#{bundle_filename}"
1620
+ resolve_server_bundle_source_path("app/javascript/packs/#{bundle_filename}")
1543
1621
  end
1544
1622
 
1545
1623
  def server_bundle_filename
@@ -1562,6 +1640,27 @@ module ReactOnRails
1562
1640
  "server-bundle.js"
1563
1641
  end
1564
1642
 
1643
+ def resolve_server_bundle_source_path(bundle_path)
1644
+ return bundle_path if File.exist?(bundle_path)
1645
+
1646
+ base_path = bundle_path.sub(%r{\.[^./]+\z}, "")
1647
+
1648
+ candidate_extensions = server_bundle_source_extensions_for(bundle_path)
1649
+ candidate_extensions.each do |extension|
1650
+ candidate_path = "#{base_path}#{extension}"
1651
+ return candidate_path if File.exist?(candidate_path)
1652
+ end
1653
+
1654
+ bundle_path
1655
+ end
1656
+
1657
+ def server_bundle_source_extensions_for(bundle_path)
1658
+ extension = File.extname(bundle_path)
1659
+ return SERVER_BUNDLE_SOURCE_EXTENSIONS if extension.empty?
1660
+
1661
+ SERVER_BUNDLE_SOURCE_EXTENSIONS.reject { |candidate_extension| candidate_extension == extension }
1662
+ end
1663
+
1565
1664
  def exit_with_status
1566
1665
  if checker.errors?
1567
1666
  puts Rainbow("❌ Doctor found critical issues. Please address errors above.").red.bold
@@ -2670,7 +2769,9 @@ module ReactOnRails
2670
2769
  check_pro_initializer_existence
2671
2770
  ensure_rails_environment_loaded
2672
2771
  check_pro_renderer_mode
2673
- check_base_package_imports
2772
+ check_base_package_references
2773
+ check_deprecated_renderer_cache_task
2774
+ check_rolling_deploy_adapter
2674
2775
  end
2675
2776
 
2676
2777
  def check_pro_initializer_existence
@@ -2700,45 +2801,389 @@ module ReactOnRails
2700
2801
  checker.add_warning("⚠️ Could not detect Pro renderer mode: #{e.message}")
2701
2802
  end
2702
2803
 
2703
- # The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro',
2704
- # so `import ... from 'react-on-rails'` resolves silently loading the base package instead
2705
- # of Pro. Components registered through the base package won't have Pro features (streaming,
2706
- # caching, RSC), and may cause "component not registered" errors at runtime.
2707
- BASE_PACKAGE_IMPORT_PATTERN = %r{\bfrom\s+['"]react-on-rails(?:/[^'"]*)?['"]}
2708
- BASE_PACKAGE_REQUIRE_PATTERN = %r{\brequire\s*\(\s*['"]react-on-rails(?:/[^'"]*)?['"]\s*\)}
2804
+ def check_deprecated_renderer_cache_task
2805
+ # Resolve against Rails.root (not Dir.pwd) so the scan still fires when
2806
+ # doctor is invoked from a subdirectory otherwise the checks silently
2807
+ # find nothing and the deprecation warning never surfaces.
2808
+ #
2809
+ # Read in binary mode (the task name is pure ASCII) so a non-UTF-8 byte
2810
+ # in a deploy script does not raise Encoding::InvalidByteSequenceError
2811
+ # and mask the file via the rescue below.
2812
+ #
2813
+ # Skip leading-comment lines (`#` is the comment prefix for all scanned
2814
+ # file types: Procfile, Dockerfile, shell scripts, YAML, and Ruby) so
2815
+ # files that mention the old task only inside a comment do not trip the
2816
+ # migration nudge.
2817
+ # Per-file rescue so a transient failure on one path (e.g. Errno::EACCES)
2818
+ # does not abort the whole scan and silently skip the rest. The outer
2819
+ # rescue catches anything that escapes the per-file guard. Globs are
2820
+ # expanded under their own rescue so a failure expanding one pattern
2821
+ # cannot stop other patterns or fixed paths from being scanned.
2822
+ candidate_paths = (
2823
+ RENDERER_CACHE_DEPLOY_SCRIPT_PATHS + expand_renderer_cache_deploy_script_globs
2824
+ ).uniq
2825
+
2826
+ matches = candidate_paths.select do |path|
2827
+ deploy_script_path_references_deprecated_task?(path)
2828
+ end
2829
+
2830
+ return if matches.empty?
2709
2831
 
2710
- def check_base_package_imports # rubocop:disable Metrics/CyclomaticComplexity
2711
- source_path = resolve_js_source_path
2712
- js_extensions = %w[js jsx ts tsx]
2713
- js_patterns = js_extensions.map { |ext| "#{source_path}/**/*.#{ext}" }
2714
- files_with_base_import = []
2832
+ checker.add_warning(<<~MSG.strip)
2833
+ ⚠️ Deprecated rake task '#{DEPRECATED_RENDERER_CACHE_TASK}' referenced in:
2834
+ #{matches.map { |p| format_renderer_cache_migration_bullet(p) }.join("\n")}
2715
2835
 
2716
- js_patterns.each do |pattern|
2717
- Dir.glob(pattern).each do |file|
2718
- content = File.read(file)
2719
- next unless content.match?(BASE_PACKAGE_IMPORT_PATTERN) || content.match?(BASE_PACKAGE_REQUIRE_PATTERN)
2836
+ The unified 'pre_seed_renderer_cache' task uses MODE=copy by default (for
2837
+ Docker/image builds) and MODE=symlink for same-filesystem workflows.
2838
+ MSG
2839
+ rescue StandardError => e
2840
+ checker.add_warning("⚠️ Could not complete scan for deprecated renderer-cache task references: #{e.message}")
2841
+ end
2842
+
2843
+ def deploy_script_references_deprecated_task?(full_path)
2844
+ # Only `#` comments matter for the scanned file types: Procfile, Dockerfile*,
2845
+ # and bin/* scripts all use `#`. None use `//`, so we don't filter it.
2846
+ # The trailing-comment strip requires whitespace before `#`, so a fragment
2847
+ # like `task#name` stays intact while `cmd # was: <deprecated>` is filtered.
2848
+ full_path.binread.each_line.any? do |line|
2849
+ stripped = line.lstrip
2850
+ next false if stripped.start_with?("#")
2851
+
2852
+ without_inline_comment = stripped.sub(/ +#.*/, "")
2853
+ without_inline_comment.include?(DEPRECATED_RENDERER_CACHE_TASK)
2854
+ end
2855
+ end
2720
2856
 
2721
- files_with_base_import << file
2857
+ def deploy_script_path_references_deprecated_task?(path)
2858
+ full_path = Rails.root.join(path)
2859
+
2860
+ return false unless full_path.file?
2861
+ # Skip files larger than RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES;
2862
+ # deploy scripts and CI manifests should be tiny.
2863
+ return false if full_path.size > RENDERER_CACHE_DEPLOY_SCRIPT_MAX_BYTES
2864
+
2865
+ deploy_script_references_deprecated_task?(full_path)
2866
+ rescue StandardError => e
2867
+ checker.add_warning(
2868
+ "⚠️ Could not scan #{path} for deprecated renderer-cache task references: #{e.message}"
2869
+ )
2870
+ false
2871
+ end
2872
+
2873
+ def expand_renderer_cache_deploy_script_globs
2874
+ # File::FNM_PATHNAME stops `*` from crossing slashes even though none of
2875
+ # the patterns use `**`. base: scopes the expansion to the project root
2876
+ # and yields paths relative to it. Each pattern is rescued individually
2877
+ # so a permission error on one glob (e.g. an unreadable .github/) does
2878
+ # not silence the rest.
2879
+ root = Rails.root.to_s
2880
+ RENDERER_CACHE_DEPLOY_SCRIPT_GLOBS.flat_map do |pattern|
2881
+ Dir.glob(pattern, File::FNM_PATHNAME, base: root)
2882
+ .sort
2883
+ .first(RENDERER_CACHE_DEPLOY_SCRIPT_GLOB_MAX_MATCHES)
2884
+ rescue StandardError => e
2885
+ checker.add_warning(
2886
+ "⚠️ Could not expand renderer-cache deploy-script glob #{pattern}: #{e.message}"
2887
+ )
2888
+ []
2889
+ end
2890
+ end
2891
+
2892
+ def format_renderer_cache_migration_bullet(path)
2893
+ suggestion = renderer_cache_migration_suggestion(path)
2894
+ lines = suggestion.split("\n")
2895
+ return " • #{path} → #{suggestion}" if lines.length == 1
2896
+
2897
+ indented = lines.map { |line| " #{line}" }.join("\n")
2898
+ " • #{path} →\n#{indented}"
2899
+ end
2900
+
2901
+ def renderer_cache_migration_suggestion(path)
2902
+ # Dockerfile* entries are RUN steps during image build, so copy mode bakes the cache into the layer.
2903
+ # Runtime hooks (Procfile, bin/*, .kamal/deploy.yml, Capistrano config) run after the app is deployed,
2904
+ # where both the app and renderer share the same filesystem, so symlink mode is correct.
2905
+ if path.start_with?("Dockerfile")
2906
+ # /app/.node-renderer-bundles is a placeholder matching the docs' Dockerfile examples;
2907
+ # the user must adjust it to match their image's WORKDIR. Hardcoding Rails.root here would
2908
+ # leak the developer host path (e.g. /Users/alice/myapp/...) into the Dockerfile, which
2909
+ # does not exist inside the container.
2910
+ "ENV RENDERER_SERVER_BUNDLE_CACHE_PATH=/app/.node-renderer-bundles\n" \
2911
+ "RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache"
2912
+ elsif path.start_with?(".kamal/")
2913
+ # Kamal hooks run in two contexts: post-deploy hooks on the live server (symlink),
2914
+ # and image-build hooks invoked during the Docker build (copy). The trailing comments
2915
+ # disambiguate so users pick the mode that matches the hook they're editing.
2916
+ "rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink\n" \
2917
+ "# For Kamal deploy hooks (post-deploy, on the live server): MODE=symlink\n" \
2918
+ "# For Kamal image-build hooks (hook/pre-build inside the Docker build): MODE=copy"
2919
+ else
2920
+ # docker-compose.yml / compose.yaml / bin/* / config/deploy.rb / scripts/deploy.sh
2921
+ # default to symlink (correct for local dev + same-filesystem deploys); call out the
2922
+ # copy alternative for users driving production container builds via Compose.
2923
+ "rake react_on_rails_pro:pre_seed_renderer_cache MODE=symlink # use MODE=copy for Docker/container image builds"
2924
+ end
2925
+ end
2926
+
2927
+ # ── Rolling Deploy Adapter ────────────────────────────────────────
2928
+
2929
+ ROLLING_DEPLOY_REQUIRED_METHODS = %i[previous_bundle_hashes fetch upload].freeze
2930
+
2931
+ def check_rolling_deploy_adapter
2932
+ adapter = ReactOnRailsPro.configuration.rolling_deploy_adapter
2933
+
2934
+ if adapter.nil?
2935
+ env_override = ENV.fetch("PREVIOUS_BUNDLE_HASHES", nil)
2936
+ if env_override && !env_override.empty?
2937
+ checker.add_warning(
2938
+ "⚠️ PREVIOUS_BUNDLE_HASHES=#{truncate_for_warning(env_override).inspect} is set but no " \
2939
+ "rolling_deploy_adapter is configured. Rolling-deploy seeding needs both — the env var " \
2940
+ "overrides *discovery* but the adapter is still required to fetch bundle files. " \
2941
+ "Set config.rolling_deploy_adapter or unset PREVIOUS_BUNDLE_HASHES."
2942
+ )
2943
+ else
2944
+ checker.add_info("ℹ️ No rolling_deploy_adapter configured (rolling-deploy seeding disabled).")
2722
2945
  end
2946
+ return
2947
+ end
2948
+
2949
+ return unless report_adapter_protocol(adapter)
2950
+
2951
+ env_override = ENV.fetch("PREVIOUS_BUNDLE_HASHES", nil)
2952
+ if env_override && !env_override.empty?
2953
+ # PREVIOUS_BUNDLE_HASHES is a full discovery override at runtime, so
2954
+ # probing adapter#previous_bundle_hashes here would surface timeout/error
2955
+ # noise for a code path the deploy will never invoke. Skip the probe and
2956
+ # state the override explicitly so operators see what's happening.
2957
+ checker.add_info(
2958
+ "ℹ️ PREVIOUS_BUNDLE_HASHES=#{truncate_for_warning(env_override).inspect} is set; " \
2959
+ "skipping rolling_deploy_adapter#previous_bundle_hashes probe (env var overrides discovery)."
2960
+ )
2961
+ report_resolved_cache_dir
2962
+ return
2723
2963
  end
2724
2964
 
2725
- if files_with_base_import.empty?
2726
- checker.add_success("✅ No base 'react-on-rails' imports found (Pro package used correctly)")
2965
+ report_previous_bundle_hashes_probe(adapter)
2966
+ report_resolved_cache_dir
2967
+ rescue StandardError => e
2968
+ checker.add_warning("⚠️ Could not evaluate rolling_deploy_adapter: #{e.message}")
2969
+ end
2970
+
2971
+ # Cap echoed env-var values so a malformed (or accidentally large)
2972
+ # PREVIOUS_BUNDLE_HASHES value doesn't dump kilobytes into operator output.
2973
+ PREVIOUS_BUNDLE_HASHES_DISPLAY_LIMIT = 80
2974
+ private_constant :PREVIOUS_BUNDLE_HASHES_DISPLAY_LIMIT
2975
+
2976
+ def truncate_for_warning(value)
2977
+ return value if value.length <= PREVIOUS_BUNDLE_HASHES_DISPLAY_LIMIT
2978
+
2979
+ "#{value[0, PREVIOUS_BUNDLE_HASHES_DISPLAY_LIMIT]}… (#{value.length} chars total)"
2980
+ end
2981
+
2982
+ def report_adapter_protocol(adapter)
2983
+ missing = ROLLING_DEPLOY_REQUIRED_METHODS.reject { |m| adapter.respond_to?(m) }
2984
+ if missing.empty?
2985
+ checker.add_success(
2986
+ "✅ rolling_deploy_adapter responds to all required methods " \
2987
+ "(#{ROLLING_DEPLOY_REQUIRED_METHODS.join(', ')})"
2988
+ )
2989
+ true
2990
+ else
2991
+ checker.add_warning(
2992
+ "⚠️ rolling_deploy_adapter is missing required methods: #{missing.join(', ')}. " \
2993
+ "See docs/pro/rolling-deploy-adapters.md."
2994
+ )
2995
+ false
2996
+ end
2997
+ end
2998
+
2999
+ def report_previous_bundle_hashes_probe(adapter)
3000
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
3001
+ timeout_seconds = rolling_deploy_discovery_timeout_seconds
3002
+ hashes = Timeout.timeout(timeout_seconds) { Array(adapter.previous_bundle_hashes) }
3003
+ latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
3004
+
3005
+ if hashes.empty?
3006
+ checker.add_warning(
3007
+ "⚠️ rolling_deploy_adapter#previous_bundle_hashes returned []. " \
3008
+ "Usually indicates the upload side has never run on a prior deploy."
3009
+ )
3010
+ else
3011
+ checker.add_success(
3012
+ "✅ rolling_deploy_adapter#previous_bundle_hashes returned #{hashes.length} hash(es) in #{latency_ms}ms"
3013
+ )
3014
+ end
3015
+ rescue Timeout::Error
3016
+ checker.add_warning(
3017
+ "⚠️ rolling_deploy_adapter#previous_bundle_hashes timed out after " \
3018
+ "#{timeout_seconds}s"
3019
+ )
3020
+ rescue StandardError => e
3021
+ checker.add_warning("⚠️ rolling_deploy_adapter#previous_bundle_hashes raised #{e.class}: #{e.message}")
3022
+ end
3023
+
3024
+ def rolling_deploy_discovery_timeout_seconds
3025
+ if defined?(ReactOnRailsPro::RollingDeployCacheStager::DISCOVERY_TIMEOUT_SECONDS)
3026
+ ReactOnRailsPro::RollingDeployCacheStager::DISCOVERY_TIMEOUT_SECONDS
3027
+ else
3028
+ # Must match the canonical Pro constant. Bidirectional pointers:
3029
+ # Pro file: react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb
3030
+ # Pro constant: ReactOnRailsPro::RollingDeployCacheStager::DISCOVERY_TIMEOUT_SECONDS
3031
+ # Pro guard: react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb
3032
+ # (describe "DISCOVERY_TIMEOUT_SECONDS" → expects this fallback to equal Pro constant)
3033
+ # The Pro spec catches drift in the Pro→OSS direction. If you change
3034
+ # the value here without updating the Pro constant + spec, doctor will
3035
+ # silently use a different timeout from the live stager.
3036
+ 10
3037
+ end
3038
+ end
3039
+
3040
+ # Fallback used when the Pro gem isn't loaded. Must match
3041
+ # ReactOnRailsPro::RollingDeployCacheStager::TEMPORARY_DIRECTORY_PATTERN so
3042
+ # doctor still filters staging/backup dirs out of the bundle-hash count.
3043
+ # Drift is caught by:
3044
+ # react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb
3045
+ # describe "TEMPORARY_DIRECTORY_PATTERN"
3046
+ # PID is `\d+` to match container deployments (Docker/Kubernetes) where
3047
+ # seeding runs as PID 1.
3048
+ ROLLING_DEPLOY_TEMP_DIR_PATTERN = /\.(?:staging|previous)-\d+-[0-9a-f]{8,}\z/
3049
+
3050
+ def report_resolved_cache_dir
3051
+ cache_dir = ReactOnRailsPro::Utils.resolve_renderer_cache_dir
3052
+ if File.directory?(cache_dir)
3053
+ temp_dir_pattern = rolling_deploy_temp_dir_pattern
3054
+ subdirs = Dir.children(cache_dir).select do |entry|
3055
+ File.directory?(File.join(cache_dir, entry)) && !entry.match?(temp_dir_pattern)
3056
+ end
3057
+ checker.add_info("ℹ️ Resolved renderer cache dir: #{cache_dir} (#{subdirs.length} bundle-hash subdir(s))")
3058
+ else
3059
+ checker.add_info("ℹ️ Resolved renderer cache dir: #{cache_dir} (does not exist yet)")
3060
+ end
3061
+ end
3062
+
3063
+ def rolling_deploy_temp_dir_pattern
3064
+ if defined?(ReactOnRailsPro::RollingDeployCacheStager::TEMPORARY_DIRECTORY_PATTERN)
3065
+ ReactOnRailsPro::RollingDeployCacheStager::TEMPORARY_DIRECTORY_PATTERN
3066
+ else
3067
+ ROLLING_DEPLOY_TEMP_DIR_PATTERN
3068
+ end
3069
+ end
3070
+
3071
+ # The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro',
3072
+ # so references to 'react-on-rails' resolve silently, loading the base package instead of Pro.
3073
+ # Components registered through the base package won't have Pro features (streaming, caching,
3074
+ # RSC), and may cause "component not registered" errors at runtime.
3075
+ BASE_PACKAGE_IMPORT_PATTERN = %r{\bfrom\s+['"]react-on-rails(?:/[^'"]*)?['"]}
3076
+ BASE_PACKAGE_REQUIRE_PATTERN = %r{\brequire\s*\(\s*['"]react-on-rails(?:/[^'"]*)?['"]\s*\)}
3077
+ BASE_PACKAGE_DYNAMIC_IMPORT_PATTERN = %r{\bimport\s*\(\s*['"]react-on-rails(?:/[^'"]*)?['"]\s*\)}
3078
+ BASE_PACKAGE_SIDE_EFFECT_IMPORT_PATTERN = %r{^\s*import\s+['"]react-on-rails(?:/[^'"]*)?['"]}
3079
+ BASE_PACKAGE_REFERENCE_SOURCE_ROOTS = ReactOnRails::ProMigration::JS_SOURCE_ROOTS
3080
+ BASE_PACKAGE_REFERENCE_EXTENSIONS = ReactOnRails::ProMigration::JS_SOURCE_EXTENSIONS
3081
+ # Explicit allowlist of documented Jest/Vitest APIs whose first argument is a module specifier.
3082
+ BASE_PACKAGE_JEST_MODULE_SPECIFIER_METHOD_PATTERN =
3083
+ ReactOnRails::ProMigration::JEST_MODULE_SPECIFIER_METHOD_PATTERN
3084
+ BASE_PACKAGE_VITEST_MODULE_SPECIFIER_METHOD_PATTERN =
3085
+ ReactOnRails::ProMigration::VITEST_MODULE_SPECIFIER_METHOD_PATTERN
3086
+ # Match known Jest/Vitest module-specifier helpers. Aliased or nested receivers
3087
+ # are intentionally out of scope to avoid warning on arbitrary application methods.
3088
+ #
3089
+ # importActual/importMock exist only as vi.* methods; there is no
3090
+ # `import { importActual } from 'vitest'` form. The bare branch below is a
3091
+ # deliberately broad detector heuristic (the rewriter omits it because
3092
+ # rewriting is destructive while detection is advisory) and accepts that a
3093
+ # user-defined helper of that name taking a 'react-on-rails' string matches.
3094
+ BASE_PACKAGE_MOCK_PATTERN = %r{
3095
+ \b(?:
3096
+ (?:
3097
+ jest\.(?:#{BASE_PACKAGE_JEST_MODULE_SPECIFIER_METHOD_PATTERN})
3098
+ |
3099
+ vi\.(?:#{BASE_PACKAGE_VITEST_MODULE_SPECIFIER_METHOD_PATTERN})
3100
+ )
3101
+ \s*
3102
+ (?:<[^;\n]*>\s*)?
3103
+ |
3104
+ (?:importActual|importMock)
3105
+ )
3106
+ \s*\(\s*['"]react-on-rails(?:/[^'"]*)?['"]
3107
+ }x
3108
+ # In Ruby, ^ matches the start of any line, so this catches declarations anywhere in the file.
3109
+ BASE_PACKAGE_DECLARE_MODULE_PATTERN = %r{^\s*(?:export\s+)?declare\s+module\s+['"]react-on-rails(?:/[^'"]*)?['"]}
3110
+ BASE_PACKAGE_REFERENCE_PATTERNS = [
3111
+ BASE_PACKAGE_IMPORT_PATTERN,
3112
+ BASE_PACKAGE_REQUIRE_PATTERN,
3113
+ BASE_PACKAGE_DYNAMIC_IMPORT_PATTERN,
3114
+ BASE_PACKAGE_SIDE_EFFECT_IMPORT_PATTERN,
3115
+ BASE_PACKAGE_MOCK_PATTERN,
3116
+ BASE_PACKAGE_DECLARE_MODULE_PATTERN
3117
+ ].freeze
3118
+
3119
+ def check_base_package_references
3120
+ files_with_base_reference = files_with_base_package_references(resolve_js_source_path)
3121
+
3122
+ if files_with_base_reference.empty?
3123
+ checker.add_success("✅ No base 'react-on-rails' references found (Pro package used correctly)")
2727
3124
  else
2728
3125
  checker.add_warning(<<~MSG.strip)
2729
- ⚠️ Found imports from 'react-on-rails' instead of 'react-on-rails-pro':
2730
- #{files_with_base_import.map { |f| " • #{f}" }.join("\n")}
3126
+ ⚠️ Found references to 'react-on-rails' instead of 'react-on-rails-pro':
3127
+ #{files_with_base_reference.map { |f| " • #{f}" }.join("\n")}
2731
3128
 
2732
- The base package is a transitive dependency of Pro, so these imports resolve
3129
+ Look for static imports, side-effect imports, CommonJS requires, dynamic imports,
3130
+ Jest/Vitest mock helpers, or TypeScript module augmentations.
3131
+ Note: this includes commented-out references; review each file before updating.
3132
+
3133
+ The base package is a transitive dependency of Pro, so these references resolve
2733
3134
  silently but load the base version without Pro features.
2734
3135
 
2735
- Fix: Update imports to use 'react-on-rails-pro':
2736
- import ReactOnRails from 'react-on-rails-pro'; // server
2737
- import ReactOnRails from 'react-on-rails-pro/client'; // client
3136
+ Fix: Replace base-package references with their Pro equivalents:
3137
+ import ReactOnRails from 'react-on-rails-pro'; // ES import (server)
3138
+ import ReactOnRails from 'react-on-rails-pro/client'; // ES import (client)
3139
+ import 'react-on-rails-pro'; // Side-effect import
3140
+ const ReactOnRails = require('react-on-rails-pro'); // CommonJS require
3141
+ const ReactOnRails = await import('react-on-rails-pro'); // Dynamic import
3142
+ jest.mock('react-on-rails-pro', ...); // Jest mock helper
3143
+ vi.mock('react-on-rails-pro', ...); // Vitest mock helper
3144
+ declare module 'react-on-rails-pro' { ... } // TypeScript augmentation
2738
3145
  MSG
2739
3146
  end
2740
3147
  rescue StandardError => e
2741
- checker.add_warning("⚠️ Could not scan for base package imports: #{e.message}")
3148
+ checker.add_warning("⚠️ Could not scan for base package references: #{e.message}")
3149
+ end
3150
+
3151
+ def files_with_base_package_references(source_path)
3152
+ # Scan every file type the Pro migration rewriter can modify.
3153
+ # **/*.ts naturally matches *.d.ts declaration files because they end in .ts.
3154
+ js_patterns = base_package_reference_source_paths(source_path).flat_map do |source_root|
3155
+ BASE_PACKAGE_REFERENCE_EXTENSIONS.map { |ext| "#{source_root}/**/*.#{ext}" }
3156
+ end
3157
+
3158
+ js_patterns.flat_map do |pattern|
3159
+ Dir.glob(pattern)
3160
+ .reject { |file| file.include?("/node_modules/") }
3161
+ .select { |file| base_package_reference_file?(file) }
3162
+ end.uniq.sort
3163
+ end
3164
+
3165
+ def base_package_reference_source_paths(source_path)
3166
+ ([source_path] + BASE_PACKAGE_REFERENCE_SOURCE_ROOTS)
3167
+ .compact
3168
+ .map(&:to_s)
3169
+ .reject(&:empty?)
3170
+ .uniq
3171
+ .select { |path| Dir.exist?(path) }
3172
+ end
3173
+
3174
+ def base_package_reference_file?(file)
3175
+ content = File.binread(file).force_encoding("UTF-8")
3176
+ return false unless content.valid_encoding?
3177
+
3178
+ base_package_reference?(content)
3179
+ rescue SystemCallError, IOError
3180
+ false
3181
+ end
3182
+
3183
+ def base_package_reference?(content)
3184
+ # Content-based matching intentionally catches comments and string literals
3185
+ # so stale migration references stay visible.
3186
+ BASE_PACKAGE_REFERENCE_PATTERNS.any? { |reference_pattern| content.match?(reference_pattern) }
2742
3187
  end
2743
3188
 
2744
3189
  # ── React Server Components ────────────────────────────────────
@@ -2871,7 +3316,15 @@ module ReactOnRails
2871
3316
  # Prefer the actually installed version from node_modules over the declared
2872
3317
  # range in package.json. Declared ranges like "^19.0.0" would be misleading
2873
3318
  # (stripped to "19.0.0" even though 19.0.4+ may be installed).
2874
- installed = installed_react_version
3319
+ package_root = resolved_package_root
3320
+ if package_root_missing?(package_root)
3321
+ # This check only needs the directory before Node chdirs into it; an
3322
+ # installed React version can be resolved without package.json.
3323
+ warn_missing_package_root(package_root)
3324
+ return nil
3325
+ end
3326
+
3327
+ installed = installed_react_version(package_root)
2875
3328
  return installed if installed
2876
3329
 
2877
3330
  declared_react_version
@@ -2879,11 +3332,12 @@ module ReactOnRails
2879
3332
  nil
2880
3333
  end
2881
3334
 
2882
- def installed_react_version
3335
+ def installed_react_version(package_root)
2883
3336
  # Use Node's own module resolution to find the actually installed React,
2884
3337
  # which handles hoisted dependencies in monorepos and pnpm workspaces.
2885
- stdout, _stderr, status = Open3.capture3("node", "-e",
2886
- "console.log(require.resolve('react/package.json'))")
3338
+ # Resolve from the configured package root so nested client/ layouts work.
3339
+ script = "console.log(require.resolve('react/package.json'))"
3340
+ stdout, _stderr, status = Open3.capture3("node", "-e", script, chdir: package_root)
2887
3341
  return nil unless status.success?
2888
3342
 
2889
3343
  resolved_path = stdout.strip
@@ -2895,10 +3349,24 @@ module ReactOnRails
2895
3349
  nil
2896
3350
  end
2897
3351
 
3352
+ def add_warning(message)
3353
+ checker.add_warning(message)
3354
+ end
3355
+
3356
+ # Delegates the protected registry to checker so warnings emitted from
3357
+ # Doctor share the same de-dupe state as warnings emitted from the checker.
3358
+ # The cross-class call is permitted because both Doctor and SystemChecker
3359
+ # include ConfigPathResolver, which satisfies Ruby's protected-visibility
3360
+ # rule (caller and receiver share an ancestor that defines the method).
3361
+ def config_path_warning_registry
3362
+ checker.config_path_warning_registry
3363
+ end
3364
+
2898
3365
  def declared_react_version
2899
- return nil unless File.exist?("package.json")
3366
+ package_json_path = package_json_path_for("declared React version")
3367
+ return nil unless package_json_path
2900
3368
 
2901
- package_json = JSON.parse(File.read("package.json"))
3369
+ package_json = JSON.parse(File.read(package_json_path))
2902
3370
  all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
2903
3371
  version_str = all_deps["react"]
2904
3372
  return nil unless version_str