react_on_rails 16.4.0.rc.9 → 16.4.0.rc.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdb10a9ed4626714e8eb70c05076bed77f57d4a7470a6a4e506505ee7e408c7b
4
- data.tar.gz: 1e7050122dc2c9feb0a2da0bc4152fd450a31cbc51a6229486345eed3dd08dae
3
+ metadata.gz: cdd0253666ff913084d12f9f5b3f24b41f0f33ed418165f54a5d038cb567012d
4
+ data.tar.gz: 510bcdc628f66dd6c9ddfae7be24582fef28ea35c263440e86a640b419bff379
5
5
  SHA512:
6
- metadata.gz: fa093cb5e5ac6ac0c6b69fae0de4e998e0e90b56b71c8290e421cc553bdd6a4748afafffd4cdbe8aa56f95f3fa14956eab1cf4f08b7c38cd5d29d5e3798ab0d0
7
- data.tar.gz: 4b00fb4d7606b6276f1cc23b3e7766c74b3cc4ba58b3f4e9162918714dbc9a400f3f470c7d1ed1c100678179abc11aa3fa6f94a75a6861e4e926fd796c989976
6
+ metadata.gz: fa0ec02748d25524f70fa7fe73803fc25c8c73249a17b953517ab3b226fa2101efc0443b3bab201737711f91d4a4f4d9d280e29005c28b505ae3d2d6d4bda153
7
+ data.tar.gz: 365c80cbf379d64e9c74958e96334fe494d2f9770f8fa0f1b01eae03f89e11fd08590459bbe3a021f6a4e3ff697a7ad78bcfa9fd93ac0646ada6007f6d471b8a
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- react_on_rails (16.4.0.rc.9)
4
+ react_on_rails (16.4.0.rc.10)
5
5
  addressable
6
6
  connection_pool
7
7
  execjs (~> 2.5)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "fileutils"
5
+ require "erb"
5
6
  require_relative "generator_messages"
6
7
  require_relative "generator_helper"
7
8
  require_relative "js_dependency_manager"
@@ -47,6 +48,56 @@ module ReactOnRails
47
48
  default: false,
48
49
  hide: true
49
50
 
51
+ # Keep this map in sync with templates under:
52
+ # lib/generators/react_on_rails/templates/**/config/webpack/*.tt
53
+ MANAGED_WEBPACK_FILE_TEMPLATES = {
54
+ "clientWebpackConfig.js" => "base/base/config/webpack/clientWebpackConfig.js.tt",
55
+ "commonWebpackConfig.js" => "base/base/config/webpack/commonWebpackConfig.js.tt",
56
+ "test.js" => "base/base/config/webpack/test.js.tt",
57
+ "development.js" => "base/base/config/webpack/development.js.tt",
58
+ "production.js" => "base/base/config/webpack/production.js.tt",
59
+ "serverWebpackConfig.js" => "base/base/config/webpack/serverWebpackConfig.js.tt",
60
+ "rscWebpackConfig.js" => "rsc/base/config/webpack/rscWebpackConfig.js.tt",
61
+ "ServerClientOrBoth.js" => "base/base/config/webpack/ServerClientOrBoth.js.tt",
62
+ # Legacy filename generated by older React on Rails versions.
63
+ # We compare against the current options (including --pro/--rsc); if those
64
+ # don't match the original generation, cleanup intentionally preserves files.
65
+ "generateWebpackConfigs.js" => "base/base/config/webpack/ServerClientOrBoth.js.tt"
66
+ }.freeze
67
+
68
+ # Keep these helper delegates in sync with ERB calls in managed webpack
69
+ # templates. This is the intended template-facing API; missing delegates
70
+ # intentionally fail rendering and keep cleanup conservative by treating
71
+ # files as non-removable.
72
+ TemplateRenderContext = Struct.new(:generator, :config) do
73
+ def erb_binding
74
+ binding
75
+ end
76
+
77
+ def add_documentation_reference(*args)
78
+ generator.__send__(:add_documentation_reference, *args)
79
+ end
80
+
81
+ def use_pro?
82
+ generator.__send__(:use_pro?)
83
+ end
84
+
85
+ def use_rsc?
86
+ generator.__send__(:use_rsc?)
87
+ end
88
+
89
+ def shakapacker_version_9_or_higher?
90
+ generator.__send__(:shakapacker_version_9_or_higher?)
91
+ end
92
+ end
93
+
94
+ REMOVABLE_WEBPACK_FILES = (MANAGED_WEBPACK_FILE_TEMPLATES.keys +
95
+ %w[webpack.config.js]).freeze
96
+ DOCS_REFERENCE_MESSAGE = "// The source code including full typescript support is available at:"
97
+ TEMPLATE_RENDER_FAILED = Object.new.freeze # unique sentinel compared by identity via .equal?
98
+ private_constant :MANAGED_WEBPACK_FILE_TEMPLATES, :REMOVABLE_WEBPACK_FILES, :TemplateRenderContext,
99
+ :DOCS_REFERENCE_MESSAGE, :TEMPLATE_RENDER_FAILED
100
+
50
101
  def add_hello_world_route
51
102
  # RSC uses HelloServer instead of HelloWorld, but Redux still needs hello_world route
52
103
  return if use_rsc? && !options.redux?
@@ -70,9 +121,9 @@ module ReactOnRails
70
121
  .env.example
71
122
  bin/shakapacker-precompile-hook]
72
123
 
73
- # HelloServer uses the hello_world layout so React on Rails can inject generated
74
- # packs without requiring a hardcoded application.js pack entry.
75
- base_files << "app/views/layouts/hello_world.html.erb"
124
+ # react_on_rails_default layout provides empty pack tags so React on Rails can
125
+ # inject generated packs without requiring a hardcoded application.js pack entry.
126
+ base_files << "app/views/layouts/react_on_rails_default.html.erb"
76
127
 
77
128
  # HelloWorld controller only when not using RSC (RSC uses HelloServer)
78
129
  # Exception: Redux still needs the HelloWorld controller even with RSC
@@ -104,6 +155,10 @@ module ReactOnRails
104
155
  end
105
156
 
106
157
  def copy_webpack_config
158
+ # Cleanup must run before writing new webpack/rspack configs so we only
159
+ # evaluate pre-existing stale entries, never files generated in this run.
160
+ cleanup_stale_webpack_config_dir_for_rspack
161
+
107
162
  say "Adding #{using_rspack? ? 'Rspack' : 'Webpack'} config"
108
163
  base_path = "base/base"
109
164
  base_files = %w[babel.config.js
@@ -114,9 +169,7 @@ module ReactOnRails
114
169
  config/webpack/production.js
115
170
  config/webpack/serverWebpackConfig.js
116
171
  config/webpack/ServerClientOrBoth.js]
117
- config = {
118
- message: "// The source code including full typescript support is available at:"
119
- }
172
+ config = { message: DOCS_REFERENCE_MESSAGE }
120
173
  base_files.each do |file|
121
174
  template("#{base_path}/#{file}.tt", destination_config_path(file), config)
122
175
  end
@@ -183,7 +236,7 @@ module ReactOnRails
183
236
  add_configure_minitest_to_compile_assets(test_helper) if File.exist?(test_helper)
184
237
  end
185
238
 
186
- CONFIGURE_RSPEC_TO_COMPILE_ASSETS = <<-STR.strip_heredoc
239
+ CONFIGURE_RSPEC_TO_COMPILE_ASSETS = <<~STR
187
240
  RSpec.configure do |config|
188
241
  # Ensure that if we are running js tests, we are using latest webpack assets
189
242
  # This will use the defaults of :js and :server_rendering meta tags
@@ -193,7 +246,7 @@ module ReactOnRails
193
246
  end
194
247
  STR
195
248
 
196
- CONFIGURE_MINITEST_TO_COMPILE_ASSETS = <<-STR.strip_heredoc
249
+ CONFIGURE_MINITEST_TO_COMPILE_ASSETS = <<~STR
197
250
  # Ensure that tests run against fresh webpack assets.
198
251
  ActiveSupport::TestCase.setup do
199
252
  ReactOnRails::TestHelper.ensure_assets_compiled
@@ -219,7 +272,7 @@ module ReactOnRails
219
272
  existing_content = File.read(webpack_config_path)
220
273
 
221
274
  # Check if it's the standard Shakapacker config that we can safely replace
222
- if standard_shakapacker_config?(existing_content)
275
+ if standard_shakapacker_config?(existing_content, strip_comments: true)
223
276
  # Remove the file first to avoid conflict prompt, then recreate it
224
277
  remove_file(webpack_config_path, verbose: false)
225
278
  # Show what we're doing
@@ -273,25 +326,265 @@ module ReactOnRails
273
326
  end
274
327
  end
275
328
 
276
- def standard_shakapacker_config?(content)
329
+ def cleanup_stale_webpack_config_dir_for_rspack
330
+ return unless cleanup_stale_webpack_config_dir?
331
+
332
+ webpack_config_relative_dir = "config/webpack"
333
+ all_entries = stale_webpack_config_entries
334
+ unless all_entries
335
+ say_status :warning,
336
+ "Keeping #{webpack_config_relative_dir}; could not read directory entries " \
337
+ "(permission denied or path changed)",
338
+ :yellow
339
+ return
340
+ end
341
+ if all_entries.empty?
342
+ say_status :skip, "#{webpack_config_relative_dir} (empty directory, leaving as-is)", :yellow
343
+ return
344
+ end
345
+
346
+ return warn_dotfiles_only_webpack_dir(webpack_config_relative_dir, all_entries) if all_dotfiles?(all_entries)
347
+
348
+ # We only clean up known top-level files. Any directory or unknown entry is
349
+ # treated as non-removable so we don't recurse into user-managed content.
350
+ non_removable_entries = non_removable_webpack_entries(all_entries)
351
+ removable_entries = removable_webpack_entries(all_entries)
352
+ if non_removable_entries.empty?
353
+ remove_stale_webpack_dir(webpack_config_relative_dir)
354
+ return
355
+ end
356
+
357
+ if all_dotfiles?(non_removable_entries)
358
+ handle_dotfile_only_non_removable_entries(
359
+ webpack_config_relative_dir,
360
+ removable_entries,
361
+ non_removable_entries
362
+ )
363
+ return
364
+ end
365
+
366
+ removed_entries = remove_stale_webpack_files(webpack_config_relative_dir, removable_entries)
367
+ warn_non_removable_webpack_entries(webpack_config_relative_dir, non_removable_entries, removed_entries)
368
+ end
369
+
370
+ def remove_stale_webpack_dir(webpack_config_relative_dir)
371
+ # Thor's remove_dir uses paths relative to destination_root.
372
+ # TOCTOU: a file created between the removability check and remove_dir
373
+ # would be deleted. Acceptable for this generator cleanup path.
374
+ remove_dir(webpack_config_relative_dir, verbose: false)
375
+ say_status :remove,
376
+ "#{webpack_config_relative_dir} (stale webpack configs after switching to --rspack)",
377
+ :green
378
+ end
379
+
380
+ def handle_dotfile_only_non_removable_entries(
381
+ webpack_config_relative_dir,
382
+ removable_entries,
383
+ non_removable_entries
384
+ )
385
+ removed_entries = remove_stale_webpack_files(webpack_config_relative_dir, removable_entries)
386
+ warn_dotfiles_in_webpack_dir(webpack_config_relative_dir, non_removable_entries, removed_entries)
387
+ end
388
+
389
+ def cleanup_stale_webpack_config_dir?
390
+ using_rspack? && Dir.exist?(stale_webpack_config_dir)
391
+ end
392
+
393
+ def all_dotfiles?(entries)
394
+ entries.any? && entries.all? { |entry| entry.start_with?(".") }
395
+ end
396
+
397
+ def warn_dotfiles_only_webpack_dir(webpack_config_relative_dir, all_entries)
398
+ say_status :warning,
399
+ "Keeping #{webpack_config_relative_dir}; only dotfiles found: " \
400
+ "#{all_entries.join(', ')}",
401
+ :yellow
402
+ end
403
+
404
+ def warn_non_removable_webpack_entries(webpack_config_relative_dir, non_removable_entries, removed_entries = [])
405
+ non_dotfile_entries = non_removable_entries.reject { |entry| entry.start_with?(".") }
406
+ dotfiles = non_removable_entries.select { |entry| entry.start_with?(".") }
407
+ non_removable_directories, non_removable_files = non_dotfile_entries.partition do |entry|
408
+ directory_entry?(entry)
409
+ end
410
+
411
+ non_removable_sections = []
412
+ non_removable_sections << "files: #{non_removable_files.join(', ')}" if non_removable_files.any?
413
+ if non_removable_directories.any?
414
+ non_removable_sections << "directories: #{non_removable_directories.join(', ')}"
415
+ end
416
+ non_removable_sections << "dotfiles: #{dotfiles.join(', ')}" if dotfiles.any?
417
+
418
+ non_removable_sections_message = non_removable_sections.join("; ")
419
+ removed_entries_message = if removed_entries.any?
420
+ " Removed stale managed files: #{removed_entries.join(', ')}."
421
+ else
422
+ ""
423
+ end
424
+ say_status :warning,
425
+ "Keeping #{webpack_config_relative_dir}; " \
426
+ "custom/non-removable entries detected: #{non_removable_sections_message}." \
427
+ "#{removed_entries_message}",
428
+ :yellow
429
+ end
430
+
431
+ def directory_entry?(entry)
432
+ File.directory?(File.join(stale_webpack_config_dir, entry))
433
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT
434
+ false
435
+ end
436
+
437
+ def warn_dotfiles_in_webpack_dir(webpack_config_relative_dir, dotfiles, removed_entries)
438
+ removed_entries_message = if removed_entries.any?
439
+ " Removed stale managed files: #{removed_entries.join(', ')}."
440
+ else
441
+ ""
442
+ end
443
+ say_status :warning,
444
+ "Keeping #{webpack_config_relative_dir}; dotfiles present: #{dotfiles.join(', ')}. " \
445
+ "Remove them manually if you want the directory cleaned up." \
446
+ "#{removed_entries_message}",
447
+ :yellow
448
+ end
449
+
450
+ def non_removable_webpack_entries(all_entries)
451
+ all_entries.select do |entry|
452
+ entry.start_with?(".") || !removable_webpack_entry?(entry)
453
+ end
454
+ end
455
+
456
+ def removable_webpack_entries(all_entries)
457
+ all_entries.select { |entry| removable_webpack_entry?(entry) }
458
+ end
459
+
460
+ def remove_stale_webpack_files(webpack_config_relative_dir, entries)
461
+ entries.each_with_object([]) do |entry, removed_entries|
462
+ relative_path = File.join(webpack_config_relative_dir, entry)
463
+ full_path = File.join(destination_root, relative_path)
464
+ remove_file(relative_path, verbose: false)
465
+ removed_entries << entry unless File.exist?(full_path)
466
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT, Errno::ENOTDIR
467
+ # If we cannot stat after removal attempt, conservatively report as not removed.
468
+ nil
469
+ end
470
+ end
471
+
472
+ def removable_webpack_entry?(entry)
473
+ return false unless REMOVABLE_WEBPACK_FILES.include?(entry)
474
+
475
+ full_path = File.join(stale_webpack_config_dir, entry)
476
+ return false if File.symlink?(full_path)
477
+ return false unless File.file?(full_path)
478
+
479
+ content = safe_read_cleanup_file(full_path)
480
+ return false unless content
481
+
482
+ return removable_webpack_main_config?(content) if entry == "webpack.config.js"
483
+
484
+ removable_managed_webpack_entry?(entry, content)
485
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT, Errno::ENOTDIR
486
+ false
487
+ end
488
+
489
+ def removable_webpack_main_config?(content)
490
+ webpack_template = rendered_template_for_cleanup("base/base/config/webpack/webpack.config.js.tt")
491
+ return standard_shakapacker_config?(content) if webpack_template.equal?(TEMPLATE_RENDER_FAILED)
492
+
493
+ # Cleanup is deliberately comment-sensitive (unlike copy_webpack_main_config)
494
+ # so comment-only edits keep the file as a potential customization.
495
+ standard_shakapacker_config?(content) || content_matches_template?(content, webpack_template)
496
+ end
497
+
498
+ def removable_managed_webpack_entry?(entry, content)
499
+ template_path = MANAGED_WEBPACK_FILE_TEMPLATES[entry]
500
+ return false unless template_path
501
+
502
+ rendered_template = rendered_template_for_cleanup(template_path)
503
+ return false if rendered_template.equal?(TEMPLATE_RENDER_FAILED)
504
+
505
+ content_matches_template?(content, rendered_template)
506
+ end
507
+
508
+ def rendered_template_for_cleanup(template_path)
509
+ @rendered_template_cache ||= {}
510
+ @rendered_template_cache[template_path] ||= begin
511
+ # Cleanup comparisons only need the injected documentation comment.
512
+ template_doc_config = { message: DOCS_REFERENCE_MESSAGE }
513
+ template_content = File.read(File.join(self.class.source_root, template_path))
514
+ # Render against current generator options. Any mismatch is treated as non-removable,
515
+ # which is intentional because cleanup should be conservative.
516
+ # Note: files originally generated with --pro or --rsc will not match when the
517
+ # current run omits those options; in that case, we preserve the directory.
518
+ # Templates rely on config[:message] plus a small helper subset exposed by
519
+ # TemplateRenderContext (add_documentation_reference, use_pro?, use_rsc?,
520
+ # shakapacker_version_9_or_higher?). Missing method delegates raise
521
+ # NoMethodError and are caught below, treating the file as non-removable.
522
+ # Missing config hash keys return nil silently, so any new config key
523
+ # required by templates must be added to template_doc_config above.
524
+ # Use TemplateRenderContext#erb_binding to avoid leaking method-local
525
+ # variables from rendered_template_for_cleanup into the ERB scope.
526
+ template_render_context = TemplateRenderContext.new(self, template_doc_config)
527
+ ERB.new(template_content, trim_mode: "-").result(template_render_context.erb_binding)
528
+ rescue StandardError => e
529
+ say_status :warning,
530
+ "Could not render template #{template_path} for cleanup check (#{e.class}: #{e.message}); " \
531
+ "treating as non-removable",
532
+ :yellow
533
+ # Rendering failures should never abort installation. Returning a sentinel
534
+ # guarantees a non-match so the file is treated as non-removable.
535
+ # We cache the sentinel intentionally (same options within one generator run)
536
+ # so repeated checks stay consistent and avoid noisy duplicate warnings.
537
+ TEMPLATE_RENDER_FAILED
538
+ end
539
+ end
540
+
541
+ def stale_webpack_config_dir
542
+ File.join(destination_root, "config/webpack")
543
+ end
544
+
545
+ def stale_webpack_config_entries
546
+ Dir.children(stale_webpack_config_dir).sort
547
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
548
+ # TOCTOU: directory may disappear between exist? check and read
549
+ nil
550
+ end
551
+
552
+ def safe_read_cleanup_file(path)
553
+ File.read(path)
554
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT, Errno::EISDIR, Errno::ENOTDIR
555
+ nil
556
+ end
557
+
558
+ def standard_shakapacker_config?(content, strip_comments: false)
559
+ # Keep strip_comments false by default so comment-only edits are treated as
560
+ # potential customizations during cleanup. Callers can pass true when they
561
+ # need historical comment-insensitive replacement matching.
277
562
  # Get the expected default config based on Shakapacker version
278
563
  expected_configs = shakapacker_default_configs
279
564
 
280
565
  # Check if the content matches any of the known default configurations
281
- expected_configs.any? { |config| content_matches_template?(content, config) }
566
+ expected_configs.any? { |config| content_matches_template?(content, config, strip_comments: strip_comments) }
282
567
  end
283
568
 
284
- def content_matches_template?(content, template)
569
+ def content_matches_template?(content, template, strip_comments: false)
285
570
  # Normalize whitespace and compare
286
- normalize_config_content(content) == normalize_config_content(template)
571
+ normalize_config_content(content, strip_comments: strip_comments) ==
572
+ normalize_config_content(template, strip_comments: strip_comments)
287
573
  end
288
574
 
289
- def normalize_config_content(content)
290
- # Remove comments, normalize whitespace, and clean up for comparison
291
- content.gsub(%r{//.*$}, "") # Remove single-line comments
292
- .gsub(%r{/\*.*?\*/}m, "") # Remove multi-line comments
293
- .gsub(/\s+/, " ") # Normalize whitespace
294
- .strip
575
+ def normalize_config_content(content, strip_comments: false)
576
+ normalized_content = content
577
+ if strip_comments
578
+ # Replacement detection historically removed inline // comments too.
579
+ # Note: this also strips `//` in string literals (for example URLs),
580
+ # which can yield false-negatives and prompt for replacement.
581
+ normalized_content = normalized_content.gsub(%r{//.*$}, "")
582
+ .gsub(%r{/\*.*?\*/}m, "")
583
+ end
584
+
585
+ # Normalize whitespace while preserving comments by default so added comments
586
+ # count as potential customizations and keep cleanup conservative.
587
+ normalized_content.gsub(/\s+/, " ").strip
295
588
  end
296
589
 
297
590
  def shakapacker_default_configs
@@ -47,11 +47,7 @@ module GeneratorMessages
47
47
  package_manager = detect_package_manager
48
48
  shakapacker_status = build_shakapacker_status_section(shakapacker_just_installed: shakapacker_just_installed)
49
49
  render_example = build_render_example(component_name: component_name, route: route, rsc: rsc)
50
- render_label = if rsc
51
- "• Streaming server rendering in app/views/#{route}/index.html.erb:"
52
- else
53
- "• Server-side rendering - Enabled with prerender option in app/views/#{route}/index.html.erb:"
54
- end
50
+ render_label = build_render_label(route: route, rsc: rsc)
55
51
 
56
52
  <<~MSG
57
53
 
@@ -90,6 +86,18 @@ module GeneratorMessages
90
86
  MSG
91
87
  end
92
88
 
89
+ # Uses relative lockfile paths resolved against Dir.pwd, so callers must invoke
90
+ # this while the current working directory is the target Rails app root.
91
+ def detect_package_manager
92
+ # Check for lock files to determine package manager
93
+ return "yarn" if File.exist?("yarn.lock")
94
+ return "pnpm" if File.exist?("pnpm-lock.yaml")
95
+ return "bun" if File.exist?("bun.lock") || File.exist?("bun.lockb")
96
+
97
+ # Default to npm (Shakapacker 8.x default) - covers package-lock.json and no lockfile
98
+ "npm"
99
+ end
100
+
93
101
  private
94
102
 
95
103
  def build_render_example(component_name:, route:, rsc:)
@@ -100,6 +108,11 @@ module GeneratorMessages
100
108
  end
101
109
  end
102
110
 
111
+ def build_render_label(route:, rsc:)
112
+ prefix = rsc ? "Streaming server rendering" : "Server-side rendering - Enabled with prerender option"
113
+ "• #{prefix} in app/views/#{route}/index.html.erb:"
114
+ end
115
+
103
116
  def build_process_manager_section
104
117
  process_manager = detect_process_manager
105
118
  if process_manager
@@ -196,17 +209,5 @@ module GeneratorMessages
196
209
  # If version detection fails, don't show a warning to avoid noise
197
210
  ""
198
211
  end
199
-
200
- def detect_package_manager
201
- # Check for lock files to determine package manager
202
- if File.exist?("yarn.lock")
203
- "yarn"
204
- elsif File.exist?("pnpm-lock.yaml")
205
- "pnpm"
206
- else
207
- # Default to npm (Shakapacker 8.x default) - covers package-lock.json and no lockfile
208
- "npm"
209
- end
210
- end
211
212
  end
212
213
  end
@@ -8,6 +8,8 @@ require_relative "generator_messages"
8
8
  require_relative "js_dependency_manager"
9
9
  require_relative "pro_setup"
10
10
  require_relative "rsc_setup"
11
+ # Load-path require: git_utils lives under react_on_rails/lib, not relative to this generator directory.
12
+ require "react_on_rails/git_utils"
11
13
 
12
14
  module ReactOnRails
13
15
  module Generators
@@ -100,9 +102,7 @@ module ReactOnRails
100
102
  if installation_prerequisites_met? || options.ignore_warnings?
101
103
  invoke_generators
102
104
  add_bin_scripts
103
- # Only add the post install message if not using Redux
104
- # Redux generator handles its own messages
105
- add_post_install_message unless options.redux?
105
+ add_post_install_message
106
106
  else
107
107
  error = <<~MSG.strip
108
108
  🚫 React on Rails generator prerequisites not met!
@@ -148,6 +148,7 @@ module ReactOnRails
148
148
  # - Without --rsc: Normal behavior (HelloWorld or HelloWorldApp based on --redux)
149
149
  if options.redux?
150
150
  invoke "react_on_rails:react_with_redux", [], { typescript: options.typescript?,
151
+ invoked_by_install: true,
151
152
  force: options[:force], skip: options[:skip],
152
153
  pretend: options[:pretend] }
153
154
  elsif !use_rsc?
@@ -242,6 +243,7 @@ module ReactOnRails
242
243
  end
243
244
 
244
245
  def ensure_shakapacker_installed
246
+ @shakapacker_setup_incomplete = false
245
247
  return if shakapacker_configured?
246
248
 
247
249
  if options[:pretend]
@@ -250,14 +252,19 @@ module ReactOnRails
250
252
  end
251
253
 
252
254
  print_shakapacker_setup_banner
253
- ensure_shakapacker_in_gemfile
255
+ gemfile_ok = ensure_shakapacker_in_gemfile
256
+ @shakapacker_setup_incomplete = true unless gemfile_ok
254
257
 
255
258
  # NOTE: File.exist?/File.read use Dir.pwd (not destination_root) because
256
259
  # Rails generators always run from the destination root. This is consistent
257
260
  # with other relative-path file checks in this generator (e.g. shakapacker_configured?).
258
261
  yml_content_before = File.exist?(SHAKAPACKER_YML_PATH) ? File.read(SHAKAPACKER_YML_PATH) : nil
259
262
 
260
- finalize_shakapacker_setup(yml_content_before) if install_shakapacker
263
+ if install_shakapacker
264
+ finalize_shakapacker_setup(yml_content_before)
265
+ else
266
+ @shakapacker_setup_incomplete = true
267
+ end
261
268
  end
262
269
 
263
270
  # Checks whether "shakapacker" is explicitly declared in this project's Gemfile.
@@ -297,6 +304,11 @@ module ReactOnRails
297
304
  end
298
305
 
299
306
  def add_post_install_message
307
+ if shakapacker_setup_incomplete?
308
+ GeneratorMessages.add_warning(incomplete_installation_message)
309
+ return
310
+ end
311
+
300
312
  # Determine what route and component will be created by the generator
301
313
  if use_rsc? && !options.redux?
302
314
  # RSC without Redux: HelloServer replaces HelloWorld
@@ -315,6 +327,67 @@ module ReactOnRails
315
327
  ))
316
328
  end
317
329
 
330
+ def shakapacker_setup_incomplete?
331
+ # Strict comparison keeps nil (unset) distinct from true.
332
+ @shakapacker_setup_incomplete == true
333
+ end
334
+
335
+ def recovery_install_command
336
+ flags = []
337
+ flags << "--redux" if options.redux?
338
+ flags << "--typescript" if options.typescript?
339
+ flags << "--rspack" if options.rspack?
340
+
341
+ if use_rsc?
342
+ flags << "--rsc"
343
+ elsif options.pro?
344
+ flags << "--pro"
345
+ end
346
+
347
+ ["rails generate react_on_rails:install", *flags].join(" ")
348
+ end
349
+
350
+ def recovery_working_tree_lines
351
+ [
352
+ "If this run created or changed files, clean up your working tree before rerunning",
353
+ "(commit, stash, or discard the partial changes), or re-run with --ignore-warnings",
354
+ "if you intentionally want to continue on a dirty tree."
355
+ ]
356
+ end
357
+
358
+ def recovery_working_tree_note
359
+ "#{recovery_working_tree_lines.join("\n")}\n"
360
+ end
361
+
362
+ def recovery_working_tree_step(step_number)
363
+ first_line, *remaining_lines = recovery_working_tree_lines
364
+ (["#{step_number}. #{first_line}"] + remaining_lines.map { |line| " #{line}" }).join("\n")
365
+ end
366
+
367
+ def incomplete_installation_message
368
+ package_install_step = "#{GeneratorMessages.detect_package_manager} install"
369
+
370
+ <<~MSG
371
+
372
+ ⚠️ React on Rails installation is incomplete.
373
+ ─────────────────────────────────────────────────────────────────────────
374
+ Shakapacker setup failed, so this app is not ready to run yet.
375
+ Avoid running ./bin/dev until Shakapacker is installed successfully.
376
+ Note: Some generator files may have been partially created during this run.
377
+
378
+ Next steps:
379
+ 1. #{Rainbow('bundle install').cyan}
380
+ 2. #{Rainbow('bundle exec rails shakapacker:install').cyan}
381
+ 3. #{Rainbow(package_install_step).cyan}
382
+ #{recovery_working_tree_step(4)}
383
+ 5. Re-run #{Rainbow(recovery_install_command).cyan}
384
+ (add #{Rainbow('--force').cyan} to overwrite files if needed)
385
+
386
+ Troubleshooting:
387
+ • https://github.com/shakacode/shakapacker/blob/main/docs/installation.md
388
+ MSG
389
+ end
390
+
318
391
  def shakapacker_loaded_in_process?(gem_name)
319
392
  Gem.loaded_specs.key?(gem_name)
320
393
  end
@@ -366,15 +439,16 @@ module ReactOnRails
366
439
  end
367
440
 
368
441
  def ensure_shakapacker_in_gemfile
369
- return if shakapacker_in_gemfile?
442
+ return true if shakapacker_in_gemfile?
370
443
 
371
444
  say "📝 Adding Shakapacker to Gemfile...", :yellow
372
445
  # Use with_unbundled_env to prevent inheriting BUNDLE_GEMFILE from parent process
373
446
  # See: https://github.com/shakacode/react_on_rails/issues/2287
374
447
  success = Bundler.with_unbundled_env { system("bundle add shakapacker --strict") }
375
- return if success
448
+ return true if success
376
449
 
377
450
  handle_shakapacker_gemfile_error
451
+ false
378
452
  end
379
453
 
380
454
  def install_shakapacker
@@ -443,7 +517,8 @@ module ReactOnRails
443
517
  Please try manually:
444
518
  bundle add shakapacker --strict
445
519
 
446
- Then re-run: rails generate react_on_rails:install
520
+ #{recovery_working_tree_note}
521
+ Then re-run: #{recovery_install_command}
447
522
  MSG
448
523
  GeneratorMessages.add_error(error)
449
524
  raise Thor::Error, error unless options.ignore_warnings?
@@ -464,7 +539,8 @@ module ReactOnRails
464
539
  2. Run: bundle install
465
540
  3. Try manually: bundle exec rails shakapacker:install
466
541
  4. Check for error output above
467
- 5. Re-run: rails generate react_on_rails:install
542
+ #{recovery_working_tree_step(5)}
543
+ 6. Re-run: #{recovery_install_command}
468
544
 
469
545
  Need help? Visit: https://github.com/shakacode/shakapacker/blob/main/docs/installation.md
470
546
  MSG
@@ -18,6 +18,11 @@ module ReactOnRails
18
18
  desc: "Generate TypeScript files",
19
19
  aliases: "-T"
20
20
 
21
+ class_option :invoked_by_install,
22
+ type: :boolean,
23
+ default: false,
24
+ hide: true
25
+
21
26
  def create_redux_directories
22
27
  # Create auto-bundling directory structure for Redux
23
28
  empty_directory("app/javascript/src/HelloWorldApp/ror_components")
@@ -90,6 +95,8 @@ module ReactOnRails
90
95
  end
91
96
 
92
97
  def add_redux_specific_messages
98
+ return if options.invoked_by_install?
99
+
93
100
  # Append Redux-specific post-install instructions
94
101
  GeneratorMessages.add_info(
95
102
  GeneratorMessages.helpful_message_after_installation(component_name: "HelloWorldApp", route: "hello_world")