react_on_rails 16.4.0.rc.9 → 16.4.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/lib/generators/react_on_rails/base_generator.rb +373 -23
  4. data/lib/generators/react_on_rails/generator_messages.rb +18 -17
  5. data/lib/generators/react_on_rails/install_generator.rb +85 -9
  6. data/lib/generators/react_on_rails/react_with_redux_generator.rb +7 -0
  7. data/lib/generators/react_on_rails/rsc_setup.rb +228 -1
  8. data/lib/generators/react_on_rails/templates/base/base/app/controllers/hello_world_controller.rb +1 -1
  9. data/lib/generators/react_on_rails/templates/base/base/app/views/layouts/{hello_world.html.erb → react_on_rails_default.html.erb} +1 -1
  10. data/lib/generators/react_on_rails/templates/base/base/config/webpack/rspack.config.js.tt +15 -0
  11. data/lib/generators/react_on_rails/templates/base/base/config/webpack/rspack.config.ts.tt +15 -0
  12. data/lib/generators/react_on_rails/templates/base/base/config/webpack/webpack.config.ts.tt +15 -0
  13. data/lib/generators/react_on_rails/templates/rsc/base/app/controllers/{hello_server_controller.rb → hello_server_controller.rb.tt} +1 -1
  14. data/lib/generators/react_on_rails/templates/rsc/base/app/views/hello_server/index.html.erb +1 -2
  15. data/lib/react_on_rails/configuration.rb +4 -4
  16. data/lib/react_on_rails/helper.rb +51 -38
  17. data/lib/react_on_rails/locales/base.rb +2 -2
  18. data/lib/react_on_rails/locales/to_js.rb +2 -2
  19. data/lib/react_on_rails/packer_utils.rb +4 -4
  20. data/lib/react_on_rails/pro_helper.rb +1 -1
  21. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +1 -1
  22. data/lib/react_on_rails/system_checker.rb +101 -28
  23. data/lib/react_on_rails/test_helper.rb +1 -1
  24. data/lib/react_on_rails/utils.rb +1 -1
  25. data/lib/react_on_rails/version.rb +1 -1
  26. data/rakelib/update_changelog.rake +146 -28
  27. metadata +7 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdb10a9ed4626714e8eb70c05076bed77f57d4a7470a6a4e506505ee7e408c7b
4
- data.tar.gz: 1e7050122dc2c9feb0a2da0bc4152fd450a31cbc51a6229486345eed3dd08dae
3
+ metadata.gz: 2e8711eddc91f2c2afd343c203a466e320e3c98231a6cea82f950f91de9c234c
4
+ data.tar.gz: 3e833073ef81b6051e2484075288e7aca18026eafb9d7e1727a84a7ae53e22fc
5
5
  SHA512:
6
- metadata.gz: fa093cb5e5ac6ac0c6b69fae0de4e998e0e90b56b71c8290e421cc553bdd6a4748afafffd4cdbe8aa56f95f3fa14956eab1cf4f08b7c38cd5d29d5e3798ab0d0
7
- data.tar.gz: 4b00fb4d7606b6276f1cc23b3e7766c74b3cc4ba58b3f4e9162918714dbc9a400f3f470c7d1ed1c100678179abc11aa3fa6f94a75a6861e4e926fd796c989976
6
+ metadata.gz: fb4f879f38e2a7fce93204c22efaf2c360dc5031ad52384d7abe3cfcf0f712365dbfed53ae3385f93106d4d9549bb36a714caacb820d633d7c21a4864dce3c00
7
+ data.tar.gz: df85baa5aecb849d6b28636975540a2102ab0f70c1efdc3b00e26923dd4524f4bda758a6153eb541c8db86dfc8e6860830c6336785e50c1d5416d12486b2d5e7
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)
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
@@ -214,19 +267,20 @@ module ReactOnRails
214
267
 
215
268
  def copy_webpack_main_config(base_path, config)
216
269
  webpack_config_path = bundler_main_config_path
270
+ template_path = bundler_main_config_template_path(base_path, webpack_config_path)
217
271
 
218
272
  if File.exist?(webpack_config_path)
219
273
  existing_content = File.read(webpack_config_path)
220
274
 
221
275
  # Check if it's the standard Shakapacker config that we can safely replace
222
- if standard_shakapacker_config?(existing_content)
276
+ if standard_shakapacker_config?(existing_content, strip_comments: true)
223
277
  # Remove the file first to avoid conflict prompt, then recreate it
224
278
  remove_file(webpack_config_path, verbose: false)
225
279
  # Show what we're doing
226
280
  say_status :replace,
227
281
  "#{webpack_config_path} (auto-upgrading from standard Shakapacker to React on Rails config)",
228
282
  :green
229
- template("#{base_path}/config/webpack/webpack.config.js.tt", webpack_config_path, config)
283
+ template(template_path, webpack_config_path, config)
230
284
  elsif react_on_rails_config?(existing_content)
231
285
  say_status :identical, "#{webpack_config_path} (already React on Rails compatible)", :blue
232
286
  # Skip - don't need to do anything
@@ -235,7 +289,7 @@ module ReactOnRails
235
289
  end
236
290
  else
237
291
  # File doesn't exist, create it
238
- template("#{base_path}/config/webpack/webpack.config.js.tt", webpack_config_path, config)
292
+ template(template_path, webpack_config_path, config)
239
293
  end
240
294
  end
241
295
 
@@ -256,7 +310,7 @@ module ReactOnRails
256
310
  say_status :create, "#{backup_path} (backup of your custom config)", :green
257
311
  end
258
312
 
259
- template("#{base_path}/config/webpack/webpack.config.js.tt", webpack_config_path, config)
313
+ template(bundler_main_config_template_path(base_path, webpack_config_path), webpack_config_path, config)
260
314
  else
261
315
  say_status :skip, webpack_config_path, :yellow
262
316
  say_status :warning,
@@ -267,34 +321,296 @@ module ReactOnRails
267
321
 
268
322
  def bundler_main_config_path
269
323
  if using_rspack?
270
- "config/rspack/rspack.config.js"
324
+ if File.exist?("config/rspack/rspack.config.ts")
325
+ "config/rspack/rspack.config.ts"
326
+ else
327
+ "config/rspack/rspack.config.js"
328
+ end
329
+ elsif File.exist?("config/webpack/webpack.config.ts")
330
+ "config/webpack/webpack.config.ts"
271
331
  else
272
332
  "config/webpack/webpack.config.js"
273
333
  end
274
334
  end
275
335
 
276
- def standard_shakapacker_config?(content)
336
+ def cleanup_stale_webpack_config_dir_for_rspack
337
+ return unless cleanup_stale_webpack_config_dir?
338
+
339
+ webpack_config_relative_dir = "config/webpack"
340
+ all_entries = stale_webpack_config_entries
341
+ unless all_entries
342
+ say_status :warning,
343
+ "Keeping #{webpack_config_relative_dir}; could not read directory entries " \
344
+ "(permission denied or path changed)",
345
+ :yellow
346
+ return
347
+ end
348
+ if all_entries.empty?
349
+ say_status :skip, "#{webpack_config_relative_dir} (empty directory, leaving as-is)", :yellow
350
+ return
351
+ end
352
+
353
+ return warn_dotfiles_only_webpack_dir(webpack_config_relative_dir, all_entries) if all_dotfiles?(all_entries)
354
+
355
+ # We only clean up known top-level files. Any directory or unknown entry is
356
+ # treated as non-removable so we don't recurse into user-managed content.
357
+ non_removable_entries = non_removable_webpack_entries(all_entries)
358
+ removable_entries = removable_webpack_entries(all_entries)
359
+ if non_removable_entries.empty?
360
+ remove_stale_webpack_dir(webpack_config_relative_dir)
361
+ return
362
+ end
363
+
364
+ if all_dotfiles?(non_removable_entries)
365
+ handle_dotfile_only_non_removable_entries(
366
+ webpack_config_relative_dir,
367
+ removable_entries,
368
+ non_removable_entries
369
+ )
370
+ return
371
+ end
372
+
373
+ removed_entries = remove_stale_webpack_files(webpack_config_relative_dir, removable_entries)
374
+ warn_non_removable_webpack_entries(webpack_config_relative_dir, non_removable_entries, removed_entries)
375
+ end
376
+
377
+ def remove_stale_webpack_dir(webpack_config_relative_dir)
378
+ # Thor's remove_dir uses paths relative to destination_root.
379
+ # TOCTOU: a file created between the removability check and remove_dir
380
+ # would be deleted. Acceptable for this generator cleanup path.
381
+ remove_dir(webpack_config_relative_dir, verbose: false)
382
+ say_status :remove,
383
+ "#{webpack_config_relative_dir} (stale webpack configs after switching to --rspack)",
384
+ :green
385
+ end
386
+
387
+ def handle_dotfile_only_non_removable_entries(
388
+ webpack_config_relative_dir,
389
+ removable_entries,
390
+ non_removable_entries
391
+ )
392
+ removed_entries = remove_stale_webpack_files(webpack_config_relative_dir, removable_entries)
393
+ warn_dotfiles_in_webpack_dir(webpack_config_relative_dir, non_removable_entries, removed_entries)
394
+ end
395
+
396
+ def cleanup_stale_webpack_config_dir?
397
+ using_rspack? && Dir.exist?(stale_webpack_config_dir)
398
+ end
399
+
400
+ def all_dotfiles?(entries)
401
+ entries.any? && entries.all? { |entry| entry.start_with?(".") }
402
+ end
403
+
404
+ def warn_dotfiles_only_webpack_dir(webpack_config_relative_dir, all_entries)
405
+ say_status :warning,
406
+ "Keeping #{webpack_config_relative_dir}; only dotfiles found: " \
407
+ "#{all_entries.join(', ')}",
408
+ :yellow
409
+ end
410
+
411
+ def warn_non_removable_webpack_entries(webpack_config_relative_dir, non_removable_entries, removed_entries = [])
412
+ non_dotfile_entries = non_removable_entries.reject { |entry| entry.start_with?(".") }
413
+ dotfiles = non_removable_entries.select { |entry| entry.start_with?(".") }
414
+ non_removable_directories, non_removable_files = non_dotfile_entries.partition do |entry|
415
+ directory_entry?(entry)
416
+ end
417
+
418
+ non_removable_sections = []
419
+ non_removable_sections << "files: #{non_removable_files.join(', ')}" if non_removable_files.any?
420
+ if non_removable_directories.any?
421
+ non_removable_sections << "directories: #{non_removable_directories.join(', ')}"
422
+ end
423
+ non_removable_sections << "dotfiles: #{dotfiles.join(', ')}" if dotfiles.any?
424
+
425
+ non_removable_sections_message = non_removable_sections.join("; ")
426
+ removed_entries_message = if removed_entries.any?
427
+ " Removed stale managed files: #{removed_entries.join(', ')}."
428
+ else
429
+ ""
430
+ end
431
+ say_status :warning,
432
+ "Keeping #{webpack_config_relative_dir}; " \
433
+ "custom/non-removable entries detected: #{non_removable_sections_message}." \
434
+ "#{removed_entries_message}",
435
+ :yellow
436
+ end
437
+
438
+ def directory_entry?(entry)
439
+ File.directory?(File.join(stale_webpack_config_dir, entry))
440
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT
441
+ false
442
+ end
443
+
444
+ def warn_dotfiles_in_webpack_dir(webpack_config_relative_dir, dotfiles, removed_entries)
445
+ removed_entries_message = if removed_entries.any?
446
+ " Removed stale managed files: #{removed_entries.join(', ')}."
447
+ else
448
+ ""
449
+ end
450
+ say_status :warning,
451
+ "Keeping #{webpack_config_relative_dir}; dotfiles present: #{dotfiles.join(', ')}. " \
452
+ "Remove them manually if you want the directory cleaned up." \
453
+ "#{removed_entries_message}",
454
+ :yellow
455
+ end
456
+
457
+ def non_removable_webpack_entries(all_entries)
458
+ all_entries.select do |entry|
459
+ entry.start_with?(".") || !removable_webpack_entry?(entry)
460
+ end
461
+ end
462
+
463
+ def removable_webpack_entries(all_entries)
464
+ all_entries.select { |entry| removable_webpack_entry?(entry) }
465
+ end
466
+
467
+ def remove_stale_webpack_files(webpack_config_relative_dir, entries)
468
+ entries.each_with_object([]) do |entry, removed_entries|
469
+ relative_path = File.join(webpack_config_relative_dir, entry)
470
+ full_path = File.join(destination_root, relative_path)
471
+ remove_file(relative_path, verbose: false)
472
+ removed_entries << entry unless File.exist?(full_path)
473
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT, Errno::ENOTDIR
474
+ # If we cannot stat after removal attempt, conservatively report as not removed.
475
+ nil
476
+ end
477
+ end
478
+
479
+ def removable_webpack_entry?(entry)
480
+ return false unless REMOVABLE_WEBPACK_FILES.include?(entry)
481
+
482
+ full_path = File.join(stale_webpack_config_dir, entry)
483
+ return false if File.symlink?(full_path)
484
+ return false unless File.file?(full_path)
485
+
486
+ content = safe_read_cleanup_file(full_path)
487
+ return false unless content
488
+
489
+ return removable_webpack_main_config?(content) if entry == "webpack.config.js"
490
+
491
+ removable_managed_webpack_entry?(entry, content)
492
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT, Errno::ENOTDIR
493
+ false
494
+ end
495
+
496
+ def removable_webpack_main_config?(content)
497
+ webpack_template = rendered_template_for_cleanup("base/base/config/webpack/webpack.config.js.tt")
498
+ return standard_shakapacker_config?(content) if webpack_template.equal?(TEMPLATE_RENDER_FAILED)
499
+
500
+ # Cleanup is deliberately comment-sensitive (unlike copy_webpack_main_config)
501
+ # so comment-only edits keep the file as a potential customization.
502
+ standard_shakapacker_config?(content) || content_matches_template?(content, webpack_template)
503
+ end
504
+
505
+ def removable_managed_webpack_entry?(entry, content)
506
+ template_path = MANAGED_WEBPACK_FILE_TEMPLATES[entry]
507
+ return false unless template_path
508
+
509
+ rendered_template = rendered_template_for_cleanup(template_path)
510
+ return false if rendered_template.equal?(TEMPLATE_RENDER_FAILED)
511
+
512
+ content_matches_template?(content, rendered_template)
513
+ end
514
+
515
+ def rendered_template_for_cleanup(template_path)
516
+ @rendered_template_cache ||= {}
517
+ @rendered_template_cache[template_path] ||= begin
518
+ # Cleanup comparisons only need the injected documentation comment.
519
+ template_doc_config = { message: DOCS_REFERENCE_MESSAGE }
520
+ template_content = File.read(File.join(self.class.source_root, template_path))
521
+ # Render against current generator options. Any mismatch is treated as non-removable,
522
+ # which is intentional because cleanup should be conservative.
523
+ # Note: files originally generated with --pro or --rsc will not match when the
524
+ # current run omits those options; in that case, we preserve the directory.
525
+ # Templates rely on config[:message] plus a small helper subset exposed by
526
+ # TemplateRenderContext (add_documentation_reference, use_pro?, use_rsc?,
527
+ # shakapacker_version_9_or_higher?). Missing method delegates raise
528
+ # NoMethodError and are caught below, treating the file as non-removable.
529
+ # Missing config hash keys return nil silently, so any new config key
530
+ # required by templates must be added to template_doc_config above.
531
+ # Use TemplateRenderContext#erb_binding to avoid leaking method-local
532
+ # variables from rendered_template_for_cleanup into the ERB scope.
533
+ template_render_context = TemplateRenderContext.new(self, template_doc_config)
534
+ ERB.new(template_content, trim_mode: "-").result(template_render_context.erb_binding)
535
+ rescue StandardError => e
536
+ say_status :warning,
537
+ "Could not render template #{template_path} for cleanup check (#{e.class}: #{e.message}); " \
538
+ "treating as non-removable",
539
+ :yellow
540
+ # Rendering failures should never abort installation. Returning a sentinel
541
+ # guarantees a non-match so the file is treated as non-removable.
542
+ # We cache the sentinel intentionally (same options within one generator run)
543
+ # so repeated checks stay consistent and avoid noisy duplicate warnings.
544
+ TEMPLATE_RENDER_FAILED
545
+ end
546
+ end
547
+
548
+ def stale_webpack_config_dir
549
+ File.join(destination_root, "config/webpack")
550
+ end
551
+
552
+ def stale_webpack_config_entries
553
+ Dir.children(stale_webpack_config_dir).sort
554
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
555
+ # TOCTOU: directory may disappear between exist? check and read
556
+ nil
557
+ end
558
+
559
+ def safe_read_cleanup_file(path)
560
+ File.read(path)
561
+ rescue Errno::EACCES, Errno::ELOOP, Errno::ENOENT, Errno::EISDIR, Errno::ENOTDIR
562
+ nil
563
+ end
564
+
565
+ def standard_shakapacker_config?(content, strip_comments: false)
566
+ # Keep strip_comments false by default so comment-only edits are treated as
567
+ # potential customizations during cleanup. Callers can pass true when they
568
+ # need historical comment-insensitive replacement matching.
277
569
  # Get the expected default config based on Shakapacker version
278
570
  expected_configs = shakapacker_default_configs
279
571
 
280
572
  # Check if the content matches any of the known default configurations
281
- expected_configs.any? { |config| content_matches_template?(content, config) }
573
+ expected_configs.any? { |config| content_matches_template?(content, config, strip_comments: strip_comments) }
282
574
  end
283
575
 
284
- def content_matches_template?(content, template)
576
+ def content_matches_template?(content, template, strip_comments: false)
285
577
  # Normalize whitespace and compare
286
- normalize_config_content(content) == normalize_config_content(template)
578
+ normalize_config_content(content, strip_comments: strip_comments) ==
579
+ normalize_config_content(template, strip_comments: strip_comments)
287
580
  end
288
581
 
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
582
+ def normalize_config_content(content, strip_comments: false)
583
+ normalized_content = content
584
+ if strip_comments
585
+ # Replacement detection historically removed inline // comments too.
586
+ # Note: this also strips `//` in string literals (for example URLs),
587
+ # which can yield false-negatives and prompt for replacement.
588
+ normalized_content = normalized_content.gsub(%r{//.*$}, "")
589
+ .gsub(%r{/\*.*?\*/}m, "")
590
+ end
591
+
592
+ # Normalize whitespace while preserving comments by default so added comments
593
+ # count as potential customizations and keep cleanup conservative.
594
+ normalized_content.gsub(/\s+/, " ")
595
+ .tr('"', "'") # Normalize quote style for import/require statements
596
+ .strip
597
+ end
598
+
599
+ def bundler_main_config_template_path(base_path, config_path)
600
+ template_ext = config_path.end_with?(".ts") ? "ts.tt" : "js.tt"
601
+ template_base = if config_path.include?("/rspack/") || File.basename(config_path).start_with?("rspack.config")
602
+ "rspack.config"
603
+ else
604
+ "webpack.config"
605
+ end
606
+ "#{base_path}/config/webpack/#{template_base}.#{template_ext}"
295
607
  end
296
608
 
297
609
  def shakapacker_default_configs
610
+ shakapacker_cjs_default_configs + shakapacker_esm_default_configs
611
+ end
612
+
613
+ def shakapacker_cjs_default_configs
298
614
  configs = []
299
615
 
300
616
  # Shakapacker v7+ (generateWebpackConfig function)
@@ -347,6 +663,40 @@ module ReactOnRails
347
663
  configs
348
664
  end
349
665
 
666
+ def shakapacker_esm_default_configs
667
+ configs = []
668
+
669
+ # Shakapacker v9.4+ TypeScript webpack configs (ESM syntax)
670
+ configs << <<~CONFIG
671
+ import { generateWebpackConfig } from 'shakapacker'
672
+ import type { Configuration } from 'webpack'
673
+ const webpackConfig: Configuration = generateWebpackConfig()
674
+ export default webpackConfig
675
+ CONFIG
676
+
677
+ configs << <<~CONFIG
678
+ import { generateWebpackConfig } from 'shakapacker'
679
+ const webpackConfig = generateWebpackConfig()
680
+ export default webpackConfig
681
+ CONFIG
682
+
683
+ # Shakapacker v9.4+ TypeScript rspack configs (ESM syntax)
684
+ configs << <<~CONFIG
685
+ import { generateRspackConfig } from 'shakapacker/rspack'
686
+ import type { RspackOptions } from '@rspack/core'
687
+ const rspackConfig: RspackOptions = generateRspackConfig()
688
+ export default rspackConfig
689
+ CONFIG
690
+
691
+ configs << <<~CONFIG
692
+ import { generateRspackConfig } from 'shakapacker/rspack'
693
+ const rspackConfig = generateRspackConfig()
694
+ export default rspackConfig
695
+ CONFIG
696
+
697
+ configs
698
+ end
699
+
350
700
  def react_on_rails_config?(content)
351
701
  # Check if it already has React on Rails environment-specific loading
352
702
  content.include?("envSpecificConfig") || content.include?("env.nodeEnv")
@@ -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