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
@@ -2,13 +2,22 @@
2
2
 
3
3
  require "rainbow"
4
4
 
5
+ require_relative "generator_messages/package_manager_detection"
6
+ require_relative "generator_messages/ci_section"
7
+ require_relative "generator_messages/shakapacker_status_section"
8
+
5
9
  module GeneratorMessages
6
10
  PRO_UPGRADE_HINT = "\n\n 💎 For RSC, streaming SSR, and 10-100x faster SSR, try React on Rails Pro:" \
7
11
  "\n #{Rainbow('https://reactonrails.com/docs/pro/upgrading-to-pro/').cyan.underline}".freeze
8
- SUPPORTED_PACKAGE_MANAGERS = %w[npm pnpm yarn bun].freeze
12
+ # Package manager constants and detection helpers live in PackageManagerDetection,
13
+ # re-exported here for backwards compatibility (external callers use ::SUPPORTED_PACKAGE_MANAGERS).
14
+ SUPPORTED_PACKAGE_MANAGERS = PackageManagerDetection::SUPPORTED_PACKAGE_MANAGERS
9
15
 
10
- # rubocop:disable Metrics/ClassLength
11
16
  class << self
17
+ include PackageManagerDetection
18
+ include CiSection
19
+ include ShakapackerStatusSection
20
+
12
21
  def output
13
22
  @output ||= []
14
23
  end
@@ -46,11 +55,14 @@ module GeneratorMessages
46
55
  end
47
56
 
48
57
  def helpful_message_after_installation(component_name: "HelloWorld", route: "hello_world", pro: false,
49
- rsc: false, shakapacker_just_installed: false, landing_page: false)
58
+ rsc: false, shakapacker_just_installed: false, landing_page: false,
59
+ ci_workflow_generated: false, app_root: Dir.pwd)
50
60
  process_manager_section = build_process_manager_section
51
- testing_section = build_testing_section
52
- package_manager = detect_package_manager
53
- shakapacker_status = build_shakapacker_status_section(shakapacker_just_installed: shakapacker_just_installed)
61
+ testing_section = build_testing_section(app_root: app_root)
62
+ ci_section = build_ci_section(app_root: app_root, ci_workflow_generated: ci_workflow_generated)
63
+ package_manager = detect_package_manager(app_root: app_root)
64
+ shakapacker_status = build_shakapacker_status_section(shakapacker_just_installed: shakapacker_just_installed,
65
+ app_root: app_root)
54
66
  render_example = build_render_example(component_name: component_name, route: route, rsc: rsc)
55
67
  render_label = build_render_label(route: route, rsc: rsc)
56
68
  normalized_route = route.to_s.sub(%r{\A/+}, "")
@@ -99,33 +111,10 @@ module GeneratorMessages
99
111
  • Documentation: #{Rainbow('https://reactonrails.com/docs/').cyan.underline}
100
112
  • Webpack customization: #{Rainbow('https://github.com/shakacode/shakapacker#webpack-configuration').cyan.underline}
101
113
 
102
- 💡 TIP: Run 'bin/dev help' for development server options and troubleshooting#{testing_section}#{pro_hint}
114
+ 💡 TIP: Run 'bin/dev help' for development server options and troubleshooting#{testing_section}#{ci_section}#{pro_hint}
103
115
  MSG
104
116
  end
105
117
 
106
- # Uses relative lockfile paths resolved against Dir.pwd, so callers must invoke
107
- # this while the current working directory is the target Rails app root.
108
- def detect_package_manager
109
- env_package_manager = ENV.fetch("REACT_ON_RAILS_PACKAGE_MANAGER", nil)&.strip&.downcase
110
- return env_package_manager if supported_package_manager?(env_package_manager)
111
-
112
- # Default to npm (Shakapacker 8.x default) - covers package-lock.json and no lockfile
113
- detect_package_manager_from_lockfiles || "npm"
114
- end
115
-
116
- def detect_package_manager_from_lockfiles
117
- return "yarn" if File.exist?("yarn.lock")
118
- return "pnpm" if File.exist?("pnpm-lock.yaml")
119
- return "bun" if File.exist?("bun.lock") || File.exist?("bun.lockb")
120
- return "npm" if File.exist?("package-lock.json")
121
-
122
- nil
123
- end
124
-
125
- def supported_package_manager?(package_manager)
126
- SUPPORTED_PACKAGE_MANAGERS.include?(package_manager)
127
- end
128
-
129
118
  private
130
119
 
131
120
  def build_render_example(component_name:, route:, rsc:)
@@ -160,8 +149,9 @@ module GeneratorMessages
160
149
  end
161
150
  end
162
151
 
163
- def build_testing_section
164
- return "" if File.exist?("spec/rails_helper.rb") || File.exist?("spec/spec_helper.rb")
152
+ def build_testing_section(app_root: Dir.pwd)
153
+ return "" if File.exist?(File.join(app_root, "spec/rails_helper.rb")) ||
154
+ File.exist?(File.join(app_root, "spec/spec_helper.rb"))
165
155
 
166
156
  <<~TESTING
167
157
 
@@ -182,52 +172,5 @@ module GeneratorMessages
182
172
  "foreman"
183
173
  end
184
174
  end
185
-
186
- def build_shakapacker_status_section(shakapacker_just_installed: false)
187
- version_warning = check_shakapacker_version_warning
188
- if shakapacker_just_installed
189
- base = <<~SHAKAPACKER
190
-
191
- 📦 SHAKAPACKER SETUP:
192
- ─────────────────────────────────────────────────────────────────────────
193
- #{Rainbow('✓ Added to Gemfile automatically').green}
194
- #{Rainbow('✓ Installer ran successfully').green}
195
- #{Rainbow('✓ Webpack integration configured').green}
196
- SHAKAPACKER
197
- base.chomp + version_warning
198
- elsif File.exist?("bin/shakapacker") && File.exist?("bin/shakapacker-dev-server")
199
- "\n📦 #{Rainbow('Shakapacker already configured ✓').green}#{version_warning}"
200
- else
201
- "\n📦 #{Rainbow('Shakapacker setup may be incomplete').yellow}#{version_warning}"
202
- end
203
- end
204
-
205
- def check_shakapacker_version_warning
206
- return "" unless File.exist?("Gemfile.lock")
207
-
208
- shakapacker_match = File.read("Gemfile.lock").match(/shakapacker \((\d+\.\d+\.\d+)\)/)
209
- return "" unless shakapacker_match
210
-
211
- version = shakapacker_match[1]
212
- if version.split(".").first.to_i < 8
213
- <<~WARNING
214
-
215
- ⚠️ #{Rainbow('IMPORTANT: Upgrade Recommended').yellow.bold}
216
- ─────────────────────────────────────────────────────────────────────────
217
- You are using Shakapacker #{version}. React on Rails v15+ works best with
218
- Shakapacker 8.0+ for optimal Hot Module Replacement and build performance.
219
-
220
- To upgrade: #{Rainbow('bundle update shakapacker').cyan}
221
-
222
- Learn more: #{Rainbow('https://github.com/shakacode/shakapacker').cyan.underline}
223
- WARNING
224
- else
225
- ""
226
- end
227
- rescue StandardError
228
- # If version detection fails, don't show a warning to avoid noise
229
- ""
230
- end
231
175
  end
232
- # rubocop:enable Metrics/ClassLength
233
176
  end
@@ -57,21 +57,13 @@ module ReactOnRails
57
57
  class_option :pro,
58
58
  type: :boolean,
59
59
  default: false,
60
- desc: "Install React on Rails Pro with Node Renderer. " \
61
- "Combined with --rsc, uses --rsc-pro mode. Default: false"
60
+ desc: "Install React on Rails Pro with Node Renderer. Default: false"
62
61
 
63
62
  # --rsc
64
63
  class_option :rsc,
65
64
  type: :boolean,
66
65
  default: false,
67
- desc: "Install React Server Components support (includes Pro). " \
68
- "Combined with --pro, uses --rsc-pro mode. Default: false"
69
-
70
- # --rsc-pro
71
- class_option :rsc_pro,
72
- type: :boolean,
73
- default: false,
74
- desc: "Install first-class Pro RSC mode with matched Pro/RSC defaults. Default: false"
66
+ desc: "Install React Server Components support (includes Pro). Default: false"
75
67
 
76
68
  # Hidden option: allows tests (and advanced users) to signal that Shakapacker
77
69
  # was just installed, triggering force-overwrite of shakapacker.yml with RoR's template.
@@ -142,6 +134,17 @@ module ReactOnRails
142
134
  SH
143
135
  ].map { |template| template.gsub("\r\n", "\n").strip }.freeze
144
136
 
137
+ # Exact fallback used when the scaffolded CI workflow has to supply a pnpm
138
+ # version because `pnpm/action-setup` requires one unless package.json declares
139
+ # `packageManager`. Match the repo's own packageManager version so generated
140
+ # CI defaults to the pnpm major this codebase tests with. Track the exact release
141
+ # used for this fallback at https://github.com/pnpm/pnpm/releases/tag/v9.14.2;
142
+ # update this URL with the constant when bumping. Users who need exact
143
+ # reproducibility should commit `packageManager` to their package.json instead.
144
+ # renovate: datasource=github-releases depName=pnpm/pnpm extractVersion=^v(?<version>.+)$
145
+ CI_PNPM_FALLBACK_VERSION = "9.14.2"
146
+ private_constant :CI_PNPM_FALLBACK_VERSION
147
+
145
148
  # Main generator entry point
146
149
  #
147
150
  # Sets up React on Rails in a Rails application by:
@@ -163,6 +166,8 @@ module ReactOnRails
163
166
 
164
167
  if installation_prerequisites_met? || options.ignore_warnings?
165
168
  invoke_generators
169
+ add_package_json_scripts
170
+ add_ci_workflow
166
171
  add_bin_scripts
167
172
  add_post_install_message
168
173
  else
@@ -251,6 +256,172 @@ module ReactOnRails
251
256
  setup_js_dependencies
252
257
  end
253
258
 
259
+ def add_ci_workflow
260
+ return if options[:pretend]
261
+
262
+ ci_path = ".github/workflows/ci.yml"
263
+ # Generators may run non-interactively (CI, scripts), so we never want Thor's
264
+ # `template` to prompt on conflict. Treat any existing workflow as "skip" by
265
+ # default; users who want to overwrite must pass --force explicitly. --skip
266
+ # falls into the same path because the desired outcome is identical.
267
+ if File.exist?(File.join(destination_root, ci_path)) && !options[:force]
268
+ say_status :skip, "#{ci_path} already exists (pass --force to overwrite)", :yellow
269
+ return
270
+ end
271
+
272
+ package_json = GeneratorMessages.read_package_json(destination_root)
273
+ package_manager = GeneratorMessages.detect_package_manager(
274
+ app_root: destination_root,
275
+ package_json: package_json
276
+ )
277
+ # Scope the lockfile check to the detected manager: a generic "any lockfile exists" check
278
+ # would emit `cache: "pnpm"` in CI when only `yarn.lock` is on disk, breaking setup-node.
279
+ has_lockfile = GeneratorMessages.lockfile_for_manager?(package_manager, app_root: destination_root)
280
+ # `pnpm/action-setup@v4` requires an explicit `version:` unless package.json declares
281
+ # `packageManager: pnpm@...`. Only ask the question for pnpm projects — other managers
282
+ # never read this flag — and require a pnpm-specific declaration so an env-override to
283
+ # pnpm while package.json declares a different manager still gets the version pin.
284
+ pnpm_version_declared = package_manager == "pnpm" &&
285
+ GeneratorMessages.package_manager_declared?(
286
+ app_root: destination_root,
287
+ manager: "pnpm",
288
+ package_json: package_json
289
+ )
290
+ has_active_record = File.exist?(File.join(destination_root, "config/database.yml"))
291
+ has_rspec = File.exist?(File.join(destination_root, "spec/rails_helper.rb")) ||
292
+ File.exist?(File.join(destination_root, "spec/spec_helper.rb"))
293
+ template("templates/base/base/.github/workflows/ci.yml.tt", ci_path,
294
+ { package_manager: package_manager, has_lockfile: has_lockfile,
295
+ pnpm_version_declared: pnpm_version_declared,
296
+ pnpm_fallback_version: CI_PNPM_FALLBACK_VERSION,
297
+ has_active_record: has_active_record, has_rspec: has_rspec })
298
+ @ci_workflow_generated = true
299
+ end
300
+
301
+ # NODE_ENV=production ensures Shakapacker emits a minified production bundle;
302
+ # without it the default is "development" which produces an unminified dev bundle
303
+ # and is almost never what `npm run build` is expected to do.
304
+ DEFAULT_PACKAGE_JSON_SCRIPTS = {
305
+ "build" => "NODE_ENV=production bin/shakapacker",
306
+ "build:test" => "RAILS_ENV=test NODE_ENV=test bin/shakapacker"
307
+ }.freeze
308
+ private_constant :DEFAULT_PACKAGE_JSON_SCRIPTS
309
+
310
+ def add_package_json_scripts
311
+ return if options[:pretend]
312
+
313
+ package_json_path = File.join(destination_root, "package.json")
314
+ return unless File.exist?(package_json_path)
315
+
316
+ original_text = File.read(package_json_path)
317
+ existing_scripts = JSON.parse(original_text)["scripts"] || {}
318
+ scripts_to_add = DEFAULT_PACKAGE_JSON_SCRIPTS.reject { |key, _| existing_scripts.key?(key) }
319
+
320
+ if scripts_to_add.empty?
321
+ say_status :skip, "build scripts already present in package.json", :yellow
322
+ return
323
+ end
324
+
325
+ updated_text = inject_scripts_into_package_json(original_text, scripts_to_add, existing_scripts)
326
+ File.write(package_json_path, updated_text)
327
+ say_status :append, "📝 Added build scripts (#{scripts_to_add.keys.join(', ')}) to package.json", :yellow
328
+ rescue JSON::ParserError => e
329
+ GeneratorMessages.add_warning("⚠️ Could not parse package.json to add scripts: #{e.message}")
330
+ rescue Errno::EACCES, Errno::ENOENT => e
331
+ GeneratorMessages.add_warning("⚠️ Failed to add build scripts to package.json: #{e.message}")
332
+ end
333
+
334
+ # Inserts new entries into the existing "scripts" object without rewriting the rest of
335
+ # package.json, so Prettier-formatted files only see the added lines in the diff.
336
+ # Falls back to a structured rewrite when the "scripts" key is absent or when the
337
+ # scripts object can't be located unambiguously (e.g. malformed JSON).
338
+ #
339
+ # Relies on the JSON invariant that `"scripts": {` cannot appear unescaped inside a
340
+ # preceding string value — in valid JSON the `"` characters are escaped as `\"`, so
341
+ # the regex can never falsely match a substring nested in a string literal.
342
+ def inject_scripts_into_package_json(original_text, scripts_to_add, existing_scripts)
343
+ opener = original_text.match(/"scripts"\s*:\s*\{/m)
344
+ return rewrite_package_json_with_scripts(original_text, scripts_to_add, existing_scripts) unless opener
345
+
346
+ inner_start = opener.end(0)
347
+ inner_end = find_matching_brace(original_text, inner_start)
348
+ return rewrite_package_json_with_scripts(original_text, scripts_to_add, existing_scripts) unless inner_end
349
+
350
+ inner = original_text[inner_start...inner_end]
351
+ # Detect the indent of the "scripts" key wherever it appears (any object position),
352
+ # not only when it's the first key. Defaults to two spaces so the closing `}` of the
353
+ # rebuilt scripts block lines up under "scripts" instead of being emitted at column 0.
354
+ object_indent = original_text[/\n([ \t]*)"scripts"/, 1] || " "
355
+ entry_indent = inner[/\n([ \t]+)"/, 1] || "#{object_indent} "
356
+ new_entries = scripts_to_add.map { |key, value| %(#{entry_indent}#{key.to_json}: #{value.to_json}) }
357
+
358
+ rebuilt_inner =
359
+ if existing_scripts.any?
360
+ trimmed = inner.sub(/\s*\z/, "")
361
+ separator = trimmed.end_with?(",") ? "" : ","
362
+ "#{trimmed}#{separator}\n#{new_entries.join(",\n")}\n#{object_indent}"
363
+ else
364
+ "\n#{new_entries.join(",\n")}\n#{object_indent}"
365
+ end
366
+
367
+ "#{original_text[0...opener.begin(0)]}\"scripts\": {#{rebuilt_inner}}#{original_text[(inner_end + 1)..]}"
368
+ end
369
+
370
+ # Returns the index of the `}` that closes the `{` whose body starts at `start`,
371
+ # or nil if the object is unterminated. Tracks brace depth while stepping through
372
+ # JSON string literals so `}` characters inside script values (e.g.
373
+ # "lint": "eslint '{src,test}/**/*.js'") do not match a non-matching brace.
374
+ def find_matching_brace(text, start)
375
+ depth = 1
376
+ i = start
377
+ while i < text.length
378
+ case text[i]
379
+ when '"'
380
+ i = skip_json_string(text, i)
381
+ return nil unless i
382
+ when "{"
383
+ depth += 1
384
+ i += 1
385
+ when "}"
386
+ depth -= 1
387
+ return i if depth.zero?
388
+
389
+ i += 1
390
+ else
391
+ i += 1
392
+ end
393
+ end
394
+ nil
395
+ end
396
+
397
+ # Given an index pointing at the opening `"` of a JSON string, returns the index
398
+ # just past the closing `"`. Honours `\"` and `\\` escapes. Returns nil if the
399
+ # string is unterminated.
400
+ def skip_json_string(text, start)
401
+ i = start + 1
402
+ while i < text.length
403
+ case text[i]
404
+ when "\\"
405
+ i += 2
406
+ when '"'
407
+ return i + 1
408
+ else
409
+ i += 1
410
+ end
411
+ end
412
+ nil
413
+ end
414
+
415
+ # Used only when the "scripts" key is missing entirely or the regex can't locate it.
416
+ # This path does reformat the whole file, but it's rare — a Rails package.json with
417
+ # no scripts key at all is unusual.
418
+ def rewrite_package_json_with_scripts(original_text, scripts_to_add, existing_scripts)
419
+ content = JSON.parse(original_text)
420
+ content["scripts"] = existing_scripts.merge(scripts_to_add)
421
+ indent = original_text[/\A\{\n(\s+)/, 1] || " "
422
+ "#{JSON.pretty_generate(content, indent: indent)}\n"
423
+ end
424
+
254
425
  def ensure_jsx_in_js_compatibility
255
426
  return if options[:pretend]
256
427
  return unless using_swc?
@@ -273,6 +444,8 @@ module ReactOnRails
273
444
  # js(.coffee) are not checked by this method, but instead produce warning messages
274
445
  # and allow the build to continue
275
446
  def installation_prerequisites_met?
447
+ warn_if_unsupported_env_package_manager
448
+
276
449
  # Non-blocking: warn about dirty worktree but don't prevent installation.
277
450
  # A clean tree makes the generator diff easier to review, but blocking would
278
451
  # be too strict for a generator that creates many new files.
@@ -298,10 +471,21 @@ module ReactOnRails
298
471
  !(missing_node? || missing_package_manager? || (!has_worktree_issues && missing_pro_gem?))
299
472
  end
300
473
 
301
- def missing_node?
302
- node_missing = ReactOnRails::Utils.running_on_windows? ? `where node`.blank? : `which node`.blank?
474
+ def warn_if_unsupported_env_package_manager
475
+ env_value = ENV.fetch("REACT_ON_RAILS_PACKAGE_MANAGER", nil)&.strip
476
+ return if env_value.nil? || env_value.empty?
477
+ return if GeneratorMessages.supported_package_manager?(env_value.downcase)
303
478
 
304
- if node_missing
479
+ supported = GeneratorMessages::SUPPORTED_PACKAGE_MANAGERS.join(", ")
480
+ GeneratorMessages.add_warning(<<~MSG.strip)
481
+ ⚠️ REACT_ON_RAILS_PACKAGE_MANAGER='#{env_value}' is not a supported package manager.
482
+ Supported values: #{supported}.
483
+ Falling through to package.json / lockfile / npm-default detection.
484
+ MSG
485
+ end
486
+
487
+ def missing_node?
488
+ unless ReactOnRails::Utils.command_available?("node")
305
489
  error = <<~MSG.strip
306
490
  🚫 Node.js is required but not found on your system.
307
491
 
@@ -409,7 +593,8 @@ module ReactOnRails
409
593
  return
410
594
  end
411
595
 
412
- # Make these and only these files executable
596
+ # Make these and only these files executable. Use destination_root so
597
+ # chmod remains correct even if an earlier generator step changed Dir.pwd.
413
598
  files_to_become_executable = bin_scripts_to_chmod(template_bin_path)
414
599
  File.chmod(0o755, *files_to_become_executable)
415
600
  end
@@ -444,7 +629,7 @@ module ReactOnRails
444
629
  def bin_scripts_to_chmod(template_bin_path)
445
630
  files = Dir.children(template_bin_path).reject { |filename| filename == "dev" }
446
631
  files << "dev" unless preserve_existing_bin_dev?
447
- files.map { |filename| "bin/#{filename}" }
632
+ files.map { |filename| File.join(destination_root, "bin/#{filename}") }
448
633
  end
449
634
 
450
635
  def default_bin_dev_route
@@ -487,9 +672,11 @@ module ReactOnRails
487
672
  pro: use_pro?,
488
673
  rsc: use_rsc?,
489
674
  shakapacker_just_installed: shakapacker_just_installed?,
490
- landing_page: options.new_app? && new_app_root_route_available?
675
+ landing_page: options.new_app? && new_app_root_route_available?,
676
+ ci_workflow_generated: @ci_workflow_generated == true,
677
+ app_root: destination_root
491
678
  ))
492
- GeneratorMessages.add_info(rsc_pro_verification_message) if use_rsc_pro_mode?
679
+ GeneratorMessages.add_info(rsc_verification_message) if use_rsc?
493
680
  end
494
681
 
495
682
  def shakapacker_setup_incomplete?
@@ -503,9 +690,7 @@ module ReactOnRails
503
690
  flags << "--typescript" if options.typescript?
504
691
  flags << "--rspack" if options.rspack?
505
692
 
506
- if use_rsc_pro_mode?
507
- flags << "--rsc-pro"
508
- elsif options.rsc?
693
+ if options.rsc?
509
694
  flags << "--rsc"
510
695
  elsif options.pro?
511
696
  flags << "--pro"
@@ -514,7 +699,7 @@ module ReactOnRails
514
699
  ["rails generate react_on_rails:install", *flags].join(" ")
515
700
  end
516
701
 
517
- def rsc_pro_verification_message
702
+ def rsc_verification_message
518
703
  <<~MSG
519
704
 
520
705
  🔎 RSC Pro Verification:
@@ -543,7 +728,7 @@ module ReactOnRails
543
728
  end
544
729
 
545
730
  def incomplete_installation_message
546
- package_install_step = "#{GeneratorMessages.detect_package_manager} install"
731
+ package_install_step = "#{GeneratorMessages.detect_package_manager(app_root: destination_root)} install"
547
732
 
548
733
  <<~MSG
549
734
 
@@ -589,8 +774,7 @@ module ReactOnRails
589
774
  end
590
775
 
591
776
  def cli_exists?(command)
592
- which_command = ReactOnRails::Utils.running_on_windows? ? "where" : "which"
593
- system(which_command, command, out: File::NULL, err: File::NULL)
777
+ ReactOnRails::Utils.command_available?(command)
594
778
  end
595
779
 
596
780
  def normalize_bin_dev_content(content)
@@ -801,13 +985,19 @@ module ReactOnRails
801
985
  end
802
986
 
803
987
  def missing_package_manager?
804
- package_managers = %w[npm pnpm yarn bun]
805
- missing = package_managers.none? { |pm| cli_exists?(pm) }
988
+ selected, source = GeneratorMessages.detect_package_manager_with_source(app_root: destination_root)
989
+ return false if GeneratorMessages.package_manager_executable_available?(selected)
990
+
991
+ available_package_managers = GeneratorMessages::SUPPORTED_PACKAGE_MANAGERS.select do |pm|
992
+ pm != selected && GeneratorMessages.package_manager_executable_available?(pm)
993
+ end
806
994
 
807
- if missing
995
+ if available_package_managers.empty?
808
996
  error = <<~MSG.strip
809
997
  🚫 No JavaScript package manager found on your system.
810
998
 
999
+ #{package_manager_source_description(selected, source)}
1000
+
811
1001
  React on Rails requires a JavaScript package manager to install dependencies.
812
1002
  Please install one of the following:
813
1003
 
@@ -822,7 +1012,32 @@ module ReactOnRails
822
1012
  return true
823
1013
  end
824
1014
 
825
- false
1015
+ action_separator = %i[default env].include?(source) ? " or " : ", update the source above, or "
1016
+ error = <<~MSG.strip
1017
+ 🚫 JavaScript package manager '#{selected}' was selected, but the command was not found.
1018
+
1019
+ #{package_manager_source_description(selected, source)}
1020
+ Install '#{selected}'#{action_separator}set REACT_ON_RAILS_PACKAGE_MANAGER
1021
+ to one of the available package managers: #{available_package_managers.join(', ')}.
1022
+ MSG
1023
+ GeneratorMessages.add_error(error)
1024
+ true
1025
+ end
1026
+
1027
+ def package_manager_source_description(selected, source)
1028
+ case source
1029
+ when :env
1030
+ "Selected via the REACT_ON_RAILS_PACKAGE_MANAGER environment variable."
1031
+ when :package_json
1032
+ "Selected via the `packageManager` field in package.json."
1033
+ when :lockfile
1034
+ lockfile = GeneratorMessages.lockfile_filename_for(selected, app_root: destination_root)
1035
+ lockfile ? "Selected via the #{lockfile} lockfile on disk." : "Selected via a lockfile on disk."
1036
+ when :default
1037
+ "Selected via the npm default fallback (no env var, packageManager field, or lockfile detected)."
1038
+ else
1039
+ raise ArgumentError, "Unknown package manager source: #{source.inspect}"
1040
+ end
826
1041
  end
827
1042
 
828
1043
  def jsx_in_js_files_present?
@@ -91,16 +91,19 @@ module ReactOnRails
91
91
  ].freeze
92
92
 
93
93
  # Rspack core dependencies (only installed when --rspack flag is used)
94
+ # @rspack/core uses ^2.0.0-0 (with -0 prerelease suffix) to include RC/beta prereleases
95
+ # of 2.0.0 until the stable 2.0.0 release lands.
94
96
  RSPACK_DEPENDENCIES = %w[
95
- @rspack/core@^1.0.0
97
+ @rspack/core@^2.0.0-0
96
98
  rspack-manifest-plugin@^5.0.0
97
99
  ].freeze
98
100
 
99
101
  # Rspack development dependencies for hot reloading
100
102
  # react-refresh is pre-1.0, so left bare (see pinning note above).
103
+ # @rspack/cli uses ^2.0.0-0 to match @rspack/core's prerelease range.
101
104
  RSPACK_DEV_DEPENDENCIES = %w[
102
- @rspack/cli@^1.0.0
103
- @rspack/plugin-react-refresh@^1.0.0
105
+ @rspack/cli@^2.0.0-0
106
+ @rspack/plugin-react-refresh@^2.0.0
104
107
  react-refresh
105
108
  ].freeze
106
109
 
@@ -557,7 +560,7 @@ module ReactOnRails
557
560
  end
558
561
 
559
562
  def fallback_package_manager
560
- package_manager = GeneratorMessages.detect_package_manager
563
+ package_manager = GeneratorMessages.detect_package_manager(app_root: destination_root)
561
564
  return package_manager if GeneratorMessages.supported_package_manager?(package_manager)
562
565
 
563
566
  "npm"
@@ -6,7 +6,7 @@ Example:
6
6
 
7
7
  This will add:
8
8
  - Pro initializer (config/initializers/react_on_rails_pro.rb)
9
- - Node renderer (client/node-renderer.js)
9
+ - Node renderer (renderer/node-renderer.js)
10
10
  - Node renderer process to Procfile.dev
11
11
 
12
12
  Modifies: