shakapacker 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/CHANGELOG.md +108 -11
  4. data/Gemfile.lock +1 -1
  5. data/README.md +182 -107
  6. data/bin/export-bundler-config +11 -0
  7. data/docs/common-upgrades.md +615 -0
  8. data/docs/deployment.md +52 -8
  9. data/docs/releasing.md +197 -0
  10. data/docs/rspack_migration_guide.md +120 -17
  11. data/docs/transpiler-migration.md +21 -0
  12. data/docs/troubleshooting.md +124 -23
  13. data/docs/typescript-migration.md +2 -1
  14. data/docs/using_swc_loader.md +108 -8
  15. data/docs/v9_upgrade.md +45 -0
  16. data/lib/install/bin/export-bundler-config +11 -0
  17. data/lib/install/bin/shakapacker +1 -1
  18. data/lib/install/bin/shakapacker-dev-server +1 -1
  19. data/lib/shakapacker/bundler_switcher.rb +329 -0
  20. data/lib/shakapacker/configuration.rb +28 -2
  21. data/lib/shakapacker/doctor.rb +65 -4
  22. data/lib/shakapacker/rspack_runner.rb +1 -1
  23. data/lib/shakapacker/runner.rb +1 -1
  24. data/lib/shakapacker/swc_migrator.rb +14 -6
  25. data/lib/shakapacker/version.rb +1 -1
  26. data/lib/shakapacker/webpack_runner.rb +1 -1
  27. data/lib/shakapacker.rb +10 -0
  28. data/lib/tasks/shakapacker/export_bundler_config.rake +72 -0
  29. data/lib/tasks/shakapacker/switch_bundler.rake +82 -0
  30. data/lib/tasks/shakapacker.rake +2 -1
  31. data/package/configExporter/cli.ts +683 -0
  32. data/package/configExporter/configDocs.ts +102 -0
  33. data/package/configExporter/fileWriter.ts +92 -0
  34. data/package/configExporter/index.ts +5 -0
  35. data/package/configExporter/types.ts +36 -0
  36. data/package/configExporter/yamlSerializer.ts +266 -0
  37. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +30 -0
  38. data/package/environments/types.ts +22 -14
  39. data/package/index.ts +12 -9
  40. data/package/swc/index.ts +5 -3
  41. data/package/types/README.md +2 -1
  42. data/package/types/index.ts +1 -0
  43. data/package/utils/debug.ts +5 -5
  44. data/package-lock.json +13047 -0
  45. data/package.json +7 -1
  46. data/yarn.lock +261 -389
  47. metadata +17 -2
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Shakapacker
7
+ # Provides functionality to switch between webpack and rspack bundlers
8
+ class BundlerSwitcher
9
+ SHAKAPACKER_CONFIG = "config/shakapacker.yml"
10
+ CUSTOM_DEPS_CONFIG = ".shakapacker-switch-bundler-dependencies.yml"
11
+
12
+ # Default dependencies for each bundler (package names only, no versions)
13
+ DEFAULT_RSPACK_DEPS = {
14
+ dev: %w[@rspack/cli @rspack/plugin-react-refresh],
15
+ prod: %w[@rspack/core rspack-manifest-plugin]
16
+ }.freeze
17
+
18
+ DEFAULT_WEBPACK_DEPS = {
19
+ dev: %w[webpack webpack-cli webpack-dev-server @pmmmwh/react-refresh-webpack-plugin @swc/core swc-loader],
20
+ prod: %w[webpack-assets-manifest webpack-merge]
21
+ }.freeze
22
+
23
+ attr_reader :root_path
24
+
25
+ def initialize(root_path = nil)
26
+ @root_path = root_path || (defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd))
27
+ end
28
+
29
+ def current_bundler
30
+ config = load_yaml_config(config_path)
31
+ config.dig("default", "assets_bundler") || "webpack"
32
+ end
33
+
34
+ def switch_to(bundler, install_deps: false, no_uninstall: false)
35
+ unless %w[webpack rspack].include?(bundler)
36
+ raise ArgumentError, "Invalid bundler: #{bundler}. Must be 'webpack' or 'rspack'"
37
+ end
38
+
39
+ current = current_bundler
40
+ if current == bundler && !install_deps
41
+ puts "✅ Already using #{bundler}"
42
+ return
43
+ end
44
+
45
+ if current == bundler && install_deps
46
+ puts "✅ Already using #{bundler} - reinstalling dependencies as requested"
47
+ manage_dependencies(bundler, install_deps, switching: false, no_uninstall: no_uninstall)
48
+ return
49
+ end
50
+
51
+ update_config(bundler)
52
+
53
+ puts "✅ Switched from #{current} to #{bundler}"
54
+ puts ""
55
+ puts "📝 Configuration updated in #{SHAKAPACKER_CONFIG}"
56
+
57
+ manage_dependencies(bundler, install_deps, no_uninstall: no_uninstall)
58
+
59
+ puts ""
60
+ puts "🎯 Next steps:"
61
+ puts " 1. Restart your dev server: bin/dev"
62
+ puts " 2. Verify build works: bin/shakapacker"
63
+ puts ""
64
+ puts "💡 Tip: Both webpack and rspack can coexist in package.json during migration"
65
+ puts " Use --install-deps to automatically manage dependencies, or manage manually"
66
+ puts " Use --no-uninstall to skip removing old bundler packages (faster switching)"
67
+ end
68
+
69
+ def init_config
70
+ if File.exist?(custom_config_path)
71
+ puts "⚠️ #{CUSTOM_DEPS_CONFIG} already exists"
72
+ return
73
+ end
74
+
75
+ config = {
76
+ "rspack" => {
77
+ "devDependencies" => DEFAULT_RSPACK_DEPS[:dev],
78
+ "dependencies" => DEFAULT_RSPACK_DEPS[:prod]
79
+ },
80
+ "webpack" => {
81
+ "devDependencies" => DEFAULT_WEBPACK_DEPS[:dev],
82
+ "dependencies" => DEFAULT_WEBPACK_DEPS[:prod]
83
+ }
84
+ }
85
+
86
+ File.write(custom_config_path, YAML.dump(config))
87
+ puts "✅ Created #{CUSTOM_DEPS_CONFIG}"
88
+ puts ""
89
+ puts "You can now customize the dependencies for each bundler in this file."
90
+ puts "The script will automatically use these custom dependencies when switching bundlers."
91
+ end
92
+
93
+ def show_usage
94
+ current = current_bundler
95
+ puts "Current bundler: #{current}"
96
+ puts ""
97
+ puts "Usage:"
98
+ puts " rails shakapacker:switch_bundler [webpack|rspack] [OPTIONS]"
99
+ puts " rake shakapacker:switch_bundler [webpack|rspack] -- [OPTIONS]"
100
+ puts ""
101
+ puts "Options:"
102
+ puts " --install-deps Automatically install/uninstall dependencies"
103
+ puts " --no-uninstall Skip uninstalling old bundler packages (faster, keeps both bundlers)"
104
+ puts " --init-config Create #{CUSTOM_DEPS_CONFIG} with default dependencies"
105
+ puts " --help, -h Show this help message"
106
+ puts ""
107
+ puts "Examples:"
108
+ puts " # Using rails command"
109
+ puts " rails shakapacker:switch_bundler rspack --install-deps"
110
+ puts " rails shakapacker:switch_bundler webpack --install-deps --no-uninstall"
111
+ puts " rails shakapacker:switch_bundler --init-config"
112
+ puts ""
113
+ puts " # Using rake command (note the -- separator)"
114
+ puts " rake shakapacker:switch_bundler rspack -- --install-deps"
115
+ puts " rake shakapacker:switch_bundler webpack -- --install-deps --no-uninstall"
116
+ puts " rake shakapacker:switch_bundler -- --init-config"
117
+ end
118
+
119
+ private
120
+
121
+ def config_path
122
+ root_path.join(SHAKAPACKER_CONFIG)
123
+ end
124
+
125
+ def custom_config_path
126
+ root_path.join(CUSTOM_DEPS_CONFIG)
127
+ end
128
+
129
+ def load_dependencies
130
+ if File.exist?(custom_config_path)
131
+ puts "📝 Using custom dependencies from #{CUSTOM_DEPS_CONFIG}"
132
+ begin
133
+ custom = load_yaml_config(custom_config_path)
134
+ rescue Psych::SyntaxError => e
135
+ puts "❌ Error parsing #{CUSTOM_DEPS_CONFIG}: #{e.message}"
136
+ puts " Please fix the YAML syntax or delete the file to use defaults"
137
+ raise
138
+ end
139
+ rspack_deps = {
140
+ dev: custom.dig("rspack", "devDependencies") || DEFAULT_RSPACK_DEPS[:dev],
141
+ prod: custom.dig("rspack", "dependencies") || DEFAULT_RSPACK_DEPS[:prod]
142
+ }
143
+ webpack_deps = {
144
+ dev: custom.dig("webpack", "devDependencies") || DEFAULT_WEBPACK_DEPS[:dev],
145
+ prod: custom.dig("webpack", "dependencies") || DEFAULT_WEBPACK_DEPS[:prod]
146
+ }
147
+ [rspack_deps, webpack_deps]
148
+ else
149
+ [DEFAULT_RSPACK_DEPS, DEFAULT_WEBPACK_DEPS]
150
+ end
151
+ end
152
+
153
+ def update_config(bundler)
154
+ content = File.read(config_path)
155
+
156
+ # Replace assets_bundler value (handles spaces, tabs, and various quote styles)
157
+ # Only matches uncommented lines
158
+ content.gsub!(/^([ \t]*assets_bundler:[ \t]*['"]?)(webpack|rspack)(['"]?)/, "\\1#{bundler}\\3")
159
+
160
+ # Update javascript_transpiler recommendation for rspack
161
+ # Only update if not already set to swc and only on uncommented lines
162
+ if bundler == "rspack" && content !~ /^[ \t]*javascript_transpiler:[ \t]*['"]?swc['"]?/
163
+ content.gsub!(/^([ \t]*javascript_transpiler:[ \t]*['"]?)\w+(['"]?)/, "\\1swc\\2")
164
+ end
165
+
166
+ File.write(config_path, content)
167
+ end
168
+
169
+ def manage_dependencies(bundler, install_deps, switching: true, no_uninstall: false)
170
+ rspack_deps, webpack_deps = load_dependencies
171
+ deps_to_install = bundler == "rspack" ? rspack_deps : webpack_deps
172
+ deps_to_remove = bundler == "rspack" ? webpack_deps : rspack_deps
173
+
174
+ if install_deps
175
+ puts ""
176
+ puts "📦 Managing dependencies..."
177
+ puts ""
178
+
179
+ # Show what will be removed (only when switching and not no_uninstall)
180
+ if switching && !no_uninstall && (!deps_to_remove[:dev].empty? || !deps_to_remove[:prod].empty?)
181
+ puts " 🗑️ Removing:"
182
+ deps_to_remove[:dev].each { |dep| puts " - #{dep} (dev)" }
183
+ deps_to_remove[:prod].each { |dep| puts " - #{dep} (prod)" }
184
+ puts ""
185
+ elsif switching && no_uninstall
186
+ puts " ⏭️ Skipping uninstall (--no-uninstall)"
187
+ puts ""
188
+ end
189
+
190
+ # Show what will be installed
191
+ if !deps_to_install[:dev].empty? || !deps_to_install[:prod].empty?
192
+ puts " 📦 Installing:"
193
+ deps_to_install[:dev].each { |dep| puts " - #{dep} (dev)" }
194
+ deps_to_install[:prod].each { |dep| puts " - #{dep} (prod)" }
195
+ puts ""
196
+ end
197
+
198
+ # Remove old bundler dependencies (only when switching and not no_uninstall)
199
+ if switching && !no_uninstall
200
+ remove_dependencies(deps_to_remove)
201
+ end
202
+
203
+ # Install new bundler dependencies
204
+ install_dependencies(deps_to_install)
205
+
206
+ puts " ✅ Dependencies updated"
207
+ else
208
+ print_manual_dependency_instructions(bundler, deps_to_install, deps_to_remove)
209
+ end
210
+ end
211
+
212
+ def remove_dependencies(deps)
213
+ package_json = get_package_json
214
+
215
+ unless deps[:dev].empty?
216
+ unless package_json.manager.remove(deps[:dev])
217
+ puts " ⚠️ Warning: Failed to uninstall some dev dependencies"
218
+ end
219
+ end
220
+
221
+ unless deps[:prod].empty?
222
+ unless package_json.manager.remove(deps[:prod])
223
+ puts " ⚠️ Warning: Failed to uninstall some prod dependencies"
224
+ end
225
+ end
226
+ end
227
+
228
+ def install_dependencies(deps)
229
+ package_json = get_package_json
230
+
231
+ unless deps[:dev].empty?
232
+ unless package_json.manager.add(deps[:dev], type: :dev)
233
+ puts "❌ Failed to install dev dependencies"
234
+ raise "Failed to install dev dependencies"
235
+ end
236
+ end
237
+
238
+ unless deps[:prod].empty?
239
+ unless package_json.manager.add(deps[:prod], type: :production)
240
+ puts "❌ Failed to install prod dependencies"
241
+ raise "Failed to install prod dependencies"
242
+ end
243
+ end
244
+
245
+ # Run a full install to ensure optional dependencies (like native bindings) are properly resolved
246
+ # This is especially important for packages like @rspack/core that use platform-specific native modules
247
+ unless package_json.manager.install
248
+ puts "❌ Failed to run full install to resolve optional dependencies"
249
+ raise "Failed to run full install"
250
+ end
251
+ end
252
+
253
+ def get_package_json
254
+ require "package_json"
255
+ PackageJson.read(root_path)
256
+ end
257
+
258
+ def print_manual_dependency_instructions(bundler, deps_to_install, deps_to_remove)
259
+ puts ""
260
+ puts "⚠️ Dependencies not automatically installed (use --install-deps to auto-install)"
261
+ puts ""
262
+
263
+ package_manager = detect_package_manager
264
+ target_name = bundler == "rspack" ? "rspack" : "webpack"
265
+ old_name = bundler == "rspack" ? "webpack" : "rspack"
266
+
267
+ puts "📦 To install #{target_name} dependencies, run:"
268
+ print_install_commands(package_manager, deps_to_install)
269
+ puts ""
270
+ puts "🗑️ To remove #{old_name} dependencies, run:"
271
+ print_uninstall_commands(package_manager, deps_to_remove)
272
+ end
273
+
274
+ def detect_package_manager
275
+ get_package_json.manager.binary
276
+ rescue StandardError
277
+ "npm" # Fallback to npm if detection fails
278
+ end
279
+
280
+ def print_install_commands(package_manager, deps)
281
+ case package_manager
282
+ when "yarn"
283
+ puts " yarn add --dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
284
+ puts " yarn add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
285
+ when "pnpm"
286
+ puts " pnpm add -D #{deps[:dev].join(' ')}" unless deps[:dev].empty?
287
+ puts " pnpm add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
288
+ when "bun"
289
+ puts " bun add --dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
290
+ puts " bun add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
291
+ else # npm
292
+ puts " npm install --save-dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
293
+ puts " npm install --save #{deps[:prod].join(' ')}" unless deps[:prod].empty?
294
+ end
295
+ end
296
+
297
+ def print_uninstall_commands(package_manager, deps)
298
+ case package_manager
299
+ when "yarn"
300
+ puts " yarn remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
301
+ puts " yarn remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
302
+ when "pnpm"
303
+ puts " pnpm remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
304
+ puts " pnpm remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
305
+ when "bun"
306
+ puts " bun remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
307
+ puts " bun remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
308
+ else # npm
309
+ puts " npm uninstall #{deps[:dev].join(' ')}" unless deps[:dev].empty?
310
+ puts " npm uninstall #{deps[:prod].join(' ')}" unless deps[:prod].empty?
311
+ end
312
+ end
313
+
314
+ # Load YAML config file with Ruby version compatibility
315
+ # Ruby 3.1+ supports aliases: keyword, older versions need YAML.safe_load
316
+ def load_yaml_config(path)
317
+ if YAML.respond_to?(:unsafe_load_file)
318
+ # Ruby 3.1+: Use unsafe_load_file to support aliases/anchors
319
+ YAML.unsafe_load_file(path)
320
+ else
321
+ # Ruby 2.7-3.0: Use safe_load with aliases enabled
322
+ YAML.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: true)
323
+ end
324
+ rescue ArgumentError
325
+ # Ruby 2.7 doesn't support aliases keyword - fall back to YAML.load
326
+ YAML.load(File.read(path)) # rubocop:disable Security/YAMLLoad
327
+ end
328
+ end
329
+ end
@@ -255,7 +255,21 @@ class Shakapacker::Configuration
255
255
  rescue ArgumentError
256
256
  YAML.load_file(config_path.to_s)
257
257
  end
258
- symbolized_config = config[env].deep_symbolize_keys
258
+
259
+ # Try to find environment-specific configuration with fallback
260
+ # Fallback order: requested env → production
261
+ if config[env]
262
+ env_config = config[env]
263
+ elsif config["production"]
264
+ log_fallback(env, "production")
265
+ env_config = config["production"]
266
+ else
267
+ # No suitable configuration found - rely on bundled defaults
268
+ log_fallback(env, "none (will use bundled defaults)")
269
+ env_config = nil
270
+ end
271
+
272
+ symbolized_config = env_config&.deep_symbolize_keys || {}
259
273
 
260
274
  return symbolized_config
261
275
  rescue Errno::ENOENT => e
@@ -280,7 +294,10 @@ class Shakapacker::Configuration
280
294
  rescue ArgumentError
281
295
  YAML.load_file(path)
282
296
  end
283
- HashWithIndifferentAccess.new(config[env] || config[Shakapacker::DEFAULT_ENV])
297
+ # Load defaults from bundled shakapacker.yml (always has all environments)
298
+ # Note: This differs from load() which reads user's config and may be missing environments
299
+ # Fallback to production ensures staging and other custom envs get production-like defaults
300
+ HashWithIndifferentAccess.new(config[env] || config["production"])
284
301
  end
285
302
  end
286
303
 
@@ -289,4 +306,13 @@ class Shakapacker::Configuration
289
306
 
290
307
  path
291
308
  end
309
+
310
+ def log_fallback(requested_env, fallback_env)
311
+ return unless Shakapacker.logger
312
+
313
+ Shakapacker.logger.info(
314
+ "Shakapacker environment '#{requested_env}' not found in #{config_path}, " \
315
+ "falling back to '#{fallback_env}'"
316
+ )
317
+ end
292
318
  end
@@ -65,12 +65,12 @@ module Shakapacker
65
65
 
66
66
  def check_entry_points
67
67
  # Check for invalid configuration first
68
- if config.data[:source_entry_path] == "/" && config.nested_entries?
68
+ if config.fetch(:source_entry_path) == "/" && config.nested_entries?
69
69
  @issues << "Invalid configuration: cannot use '/' as source_entry_path with nested_entries: true"
70
70
  return # Don't try to check files when config is invalid
71
71
  end
72
72
 
73
- source_entry_path = config.source_path.join(config.data[:source_entry_path] || "packs")
73
+ source_entry_path = config.source_path.join(config.fetch(:source_entry_path) || "packs")
74
74
 
75
75
  unless source_entry_path.exist?
76
76
  @issues << "Source entry path #{source_entry_path} does not exist"
@@ -173,7 +173,8 @@ module Shakapacker
173
173
  end
174
174
 
175
175
  def check_sri_dependencies
176
- return unless config.data.dig(:integrity, :enabled)
176
+ integrity_config = config.integrity
177
+ return unless integrity_config&.dig(:enabled)
177
178
 
178
179
  bundler = config.assets_bundler
179
180
  if bundler == "webpack"
@@ -183,7 +184,7 @@ module Shakapacker
183
184
  end
184
185
 
185
186
  # Validate hash functions
186
- hash_functions = config.data.dig(:integrity, :hash_functions) || ["sha384"]
187
+ hash_functions = integrity_config.dig(:hash_functions) || ["sha384"]
187
188
  invalid_functions = hash_functions - ["sha256", "sha384", "sha512"]
188
189
  unless invalid_functions.empty?
189
190
  @issues << "Invalid SRI hash functions: #{invalid_functions.join(', ')}"
@@ -326,6 +327,11 @@ module Shakapacker
326
327
  unless binstub_path.exist?
327
328
  @warnings << "Shakapacker binstub not found at bin/shakapacker. Run 'rails shakapacker:binstubs' to create it."
328
329
  end
330
+
331
+ export_config_binstub = root_path.join("bin/export-bundler-config")
332
+ unless export_config_binstub.exist?
333
+ @warnings << "Config export binstub not found at bin/export-bundler-config. Run 'rails shakapacker:binstubs' to create it."
334
+ end
329
335
  end
330
336
 
331
337
  def check_javascript_transpiler_dependencies
@@ -453,7 +459,51 @@ module Shakapacker
453
459
 
454
460
  if swc_config_path.exist?
455
461
  @info << "SWC configuration: Using config/swc.config.js (recommended). This config is merged with Shakapacker's defaults."
462
+ check_swc_config_settings(swc_config_path)
463
+ end
464
+ end
465
+
466
+ def check_swc_config_settings(config_path)
467
+ config_content = File.read(config_path, encoding: "UTF-8")
468
+
469
+ # Check for loose: true (deprecated default)
470
+ if config_content.match?(/loose\s*:\s*true/)
471
+ @warnings << "SWC configuration: 'loose: true' detected in config/swc.config.js. " \
472
+ "This can cause silent failures with Stimulus controllers and incorrect spread operator behavior. " \
473
+ "Consider removing this setting to use Shakapacker's default 'loose: false' (spec-compliant). " \
474
+ "See: https://github.com/shakacode/shakapacker/blob/main/docs/using_swc_loader.md#using-swc-with-stimulus"
456
475
  end
476
+
477
+ # Check for missing keepClassNames with Stimulus
478
+ if stimulus_likely_used? && !config_content.match?(/keepClassNames\s*:\s*true/)
479
+ @warnings << "SWC configuration: Stimulus appears to be in use, but 'keepClassNames: true' is not set in config/swc.config.js. " \
480
+ "Without this setting, Stimulus controllers will fail silently. " \
481
+ "Add 'keepClassNames: true' to jsc config. " \
482
+ "See: https://github.com/shakacode/shakapacker/blob/main/docs/using_swc_loader.md#using-swc-with-stimulus"
483
+ elsif config_content.match?(/keepClassNames\s*:\s*true/)
484
+ @info << "SWC configuration: 'keepClassNames: true' is set (good for Stimulus compatibility)"
485
+ end
486
+
487
+ # Check for jsc.target and env conflict
488
+ # Use word boundary to avoid false positives with transform.target or other nested properties
489
+ if config_content.match?(/jsc\s*:\s*\{[^}]*\btarget\s*:/) && config_content.match?(/env\s*:\s*\{/)
490
+ @issues << "SWC configuration: Both 'jsc.target' and 'env' are configured. These cannot be used together. " \
491
+ "Remove 'jsc.target' and use only 'env' (Shakapacker sets this automatically). " \
492
+ "See: https://github.com/shakacode/shakapacker/blob/main/docs/using_swc_loader.md#using-swc-with-stimulus"
493
+ end
494
+ rescue => e
495
+ # Don't fail doctor if SWC config check has issues
496
+ @warnings << "Unable to validate SWC configuration: #{e.message}"
497
+ end
498
+
499
+ def stimulus_likely_used?
500
+ return false unless package_json_exists?
501
+
502
+ package_json = read_package_json
503
+ dependencies = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
504
+
505
+ # Check for @hotwired/stimulus or stimulus package
506
+ dependencies.key?("@hotwired/stimulus") || dependencies.key?("stimulus")
457
507
  end
458
508
 
459
509
  def check_css_dependencies
@@ -785,6 +835,11 @@ module Shakapacker
785
835
  if binstub_path.exist?
786
836
  puts "✓ Shakapacker binstub found"
787
837
  end
838
+
839
+ export_config_binstub = doctor.root_path.join("bin/export-bundler-config")
840
+ if export_config_binstub.exist?
841
+ puts "✓ Config export binstub found"
842
+ end
788
843
  end
789
844
 
790
845
  def print_info_messages
@@ -828,6 +883,12 @@ module Shakapacker
828
883
  package_manager = doctor.send(:package_manager)
829
884
  puts "To fix missing dependencies, run:"
830
885
  puts " #{package_manager_install_command(package_manager)}"
886
+ puts ""
887
+ puts "For debugging configuration issues, export your webpack/rspack config:"
888
+ puts " bin/export-bundler-config --doctor"
889
+ puts " (Exports annotated YAML configs for dev and production - best for troubleshooting)"
890
+ puts ""
891
+ puts " See 'bin/export-bundler-config --help' for more options"
831
892
  end
832
893
 
833
894
  def package_manager_install_command(manager)
@@ -6,7 +6,7 @@ module Shakapacker
6
6
  class RspackRunner < Shakapacker::Runner
7
7
  def self.run(argv)
8
8
  $stdout.sync = true
9
- ENV["NODE_ENV"] ||= (ENV["RAILS_ENV"] == "production") ? "production" : "development"
9
+ Shakapacker.ensure_node_env!
10
10
  new(argv).run
11
11
  end
12
12
 
@@ -24,7 +24,7 @@ module Shakapacker
24
24
  ].freeze
25
25
  def self.run(argv)
26
26
  $stdout.sync = true
27
- ENV["NODE_ENV"] ||= (ENV["RAILS_ENV"] == "production") ? "production" : "development"
27
+ Shakapacker.ensure_node_env!
28
28
 
29
29
  # Create a single runner instance to avoid loading configuration twice.
30
30
  # We extend it with the appropriate build command based on the bundler type.
@@ -51,11 +51,19 @@ module Shakapacker
51
51
  // This file is merged with Shakapacker's default SWC configuration
52
52
  // See: https://swc.rs/docs/configuration/compilation
53
53
 
54
+ const { env } = require('shakapacker');
55
+
54
56
  module.exports = {
55
- jsc: {
56
- transform: {
57
- react: {
58
- runtime: "automatic"
57
+ options: {
58
+ jsc: {
59
+ // CRITICAL for Stimulus compatibility: Prevents SWC from mangling class names
60
+ // which breaks Stimulus's class-based controller discovery mechanism
61
+ keepClassNames: true,
62
+ transform: {
63
+ react: {
64
+ runtime: "automatic",
65
+ refresh: env.isDevelopment && env.runningWebpackDevServer
66
+ }
59
67
  }
60
68
  }
61
69
  }
@@ -222,8 +230,8 @@ module Shakapacker
222
230
  settings.delete("babel")
223
231
  end
224
232
 
225
- settings["swc"] = true
226
- logger.info " - Enabled SWC for #{env} environment"
233
+ settings["javascript_transpiler"] = "swc"
234
+ logger.info " - Set javascript_transpiler to 'swc' for #{env} environment"
227
235
  end
228
236
 
229
237
  File.write(config_path, config.to_yaml)
@@ -1,4 +1,4 @@
1
1
  module Shakapacker
2
2
  # Change the version in package.json too, please!
3
- VERSION = "9.0.0".freeze
3
+ VERSION = "9.2.0".freeze
4
4
  end
@@ -6,7 +6,7 @@ module Shakapacker
6
6
  class WebpackRunner < Shakapacker::Runner
7
7
  def self.run(argv)
8
8
  $stdout.sync = true
9
- ENV["NODE_ENV"] ||= (ENV["RAILS_ENV"] == "production") ? "production" : "development"
9
+ Shakapacker.ensure_node_env!
10
10
  new(argv).run
11
11
  end
12
12
 
data/lib/shakapacker.rb CHANGED
@@ -7,6 +7,9 @@ module Shakapacker
7
7
  extend self
8
8
 
9
9
  DEFAULT_ENV = "development".freeze
10
+ # Environments that use their RAILS_ENV value for NODE_ENV
11
+ # All other environments (production, staging, etc.) use "production" for webpack optimizations
12
+ DEV_TEST_ENVS = %w[development test].freeze
10
13
 
11
14
  def instance=(instance)
12
15
  @instance = instance
@@ -24,6 +27,13 @@ module Shakapacker
24
27
  ENV["NODE_ENV"] = original
25
28
  end
26
29
 
30
+ # Set NODE_ENV based on RAILS_ENV if not already set
31
+ # - development/test environments use their RAILS_ENV value
32
+ # - all other environments (production, staging, etc.) use "production" for webpack optimizations
33
+ def ensure_node_env!
34
+ ENV["NODE_ENV"] ||= DEV_TEST_ENVS.include?(ENV["RAILS_ENV"]) ? ENV["RAILS_ENV"] : "production"
35
+ end
36
+
27
37
  def ensure_log_goes_to_stdout
28
38
  old_logger = Shakapacker.logger
29
39
  Shakapacker.logger = Logger.new(STDOUT)
@@ -0,0 +1,72 @@
1
+ namespace :shakapacker do
2
+ desc <<~DESC
3
+ Export webpack or rspack configuration for debugging and analysis
4
+
5
+ Exports your resolved webpack/rspack configuration in human-readable formats.
6
+ Use this to debug configuration issues, compare environments, or analyze
7
+ client vs server bundle differences.
8
+
9
+ Usage:
10
+ rails shakapacker:export_bundler_config [OPTIONS]
11
+ rake shakapacker:export_bundler_config -- [OPTIONS]
12
+
13
+ Quick Start (Recommended):
14
+ rails shakapacker:export_bundler_config --doctor
15
+
16
+ This exports all configs (dev + prod, client + server) to shakapacker-config-exports/
17
+ directory in annotated YAML format - perfect for troubleshooting.
18
+
19
+ Common Options:
20
+ --doctor Export everything for troubleshooting (recommended)
21
+ --save Save current environment configs to files
22
+ --save-dir=<dir> Custom output directory (requires --save)
23
+ --env=development|production|test Specify environment
24
+ --client-only Export only client config
25
+ --server-only Export only server config
26
+ --format=yaml|json|inspect Output format
27
+ --help, -h Show detailed help
28
+
29
+ Examples:
30
+ # Export all configs for troubleshooting
31
+ rails shakapacker:export_bundler_config --doctor
32
+
33
+ # Save production client config
34
+ rails shakapacker:export_bundler_config --save --env=production --client-only
35
+
36
+ # View development config in terminal
37
+ rails shakapacker:export_bundler_config
38
+
39
+ # Show detailed help
40
+ rails shakapacker:export_bundler_config --help
41
+
42
+ Note: When using 'rake', you must use '--' to separate rake options from task arguments.
43
+ Example: rake shakapacker:export_bundler_config -- --doctor
44
+
45
+ The task automatically falls back to the gem version if bin/export-bundler-config
46
+ binstub is not installed. To install all binstubs, run: rails shakapacker:binstubs
47
+ DESC
48
+ task :export_bundler_config do
49
+ # Try to use the binstub if it exists, otherwise use the gem's version
50
+ bin_path = Rails.root.join("bin/export-bundler-config")
51
+
52
+ unless File.exist?(bin_path)
53
+ # Binstub not installed, use the gem's version directly
54
+ gem_bin_path = File.expand_path("../../install/bin/export-bundler-config", __dir__)
55
+
56
+ $stderr.puts "Note: bin/export-bundler-config binstub not found."
57
+ $stderr.puts "Using gem version directly. To install the binstub, run: rake shakapacker:binstubs"
58
+ $stderr.puts ""
59
+
60
+ Dir.chdir(Rails.root) do
61
+ exec("node", gem_bin_path, *ARGV[1..])
62
+ end
63
+ else
64
+ # Pass through command-line arguments after the task name
65
+ # Use exec to replace the rake process with the export script
66
+ # This ensures proper exit codes and signal handling
67
+ Dir.chdir(Rails.root) do
68
+ exec(bin_path.to_s, *ARGV[1..])
69
+ end
70
+ end
71
+ end
72
+ end