diff --git a/packages/create-react-on-rails-app/src/create-app.ts b/packages/create-react-on-rails-app/src/create-app.ts index 7147c9329e..a19a176106 100644 --- a/packages/create-react-on-rails-app/src/create-app.ts +++ b/packages/create-react-on-rails-app/src/create-app.ts @@ -226,11 +226,9 @@ export function validateAppName(name: string): { success: boolean; error?: strin export function createApp(appName: string, options: CliOptions): void { const appPath = path.resolve(process.cwd(), appName); const proRequested = options.pro || options.rsc; - let proModeLabel: string | null = null; - if (options.rsc) { - proModeLabel = '--rsc'; - } else if (options.pro) { - proModeLabel = '--pro'; + let proModeLabel: '--rsc' | '--pro' | null = null; + if (proRequested) { + proModeLabel = options.rsc ? '--rsc' : '--pro'; } const baseSteps = 3; // rails new + add react_on_rails + run generator const totalSteps = baseSteps + (proRequested ? 1 : 0); diff --git a/react_on_rails/lib/react_on_rails/config_path_resolver.rb b/react_on_rails/lib/react_on_rails/config_path_resolver.rb index 77dd3e781a..5f2b0a62f2 100644 --- a/react_on_rails/lib/react_on_rails/config_path_resolver.rb +++ b/react_on_rails/lib/react_on_rails/config_path_resolver.rb @@ -2,6 +2,18 @@ module ReactOnRails module ConfigPathResolver + # Keep JS before TS to match generator defaults and to prefer the + # JavaScript config deterministically when both variants are present. + WEBPACK_DEFAULT_CONFIG_CANDIDATES = %w[ + config/webpack/webpack.config.js + config/webpack/webpack.config.ts + ].freeze + RSPACK_DEFAULT_CONFIG_CANDIDATES = %w[ + config/rspack/rspack.config.js + config/rspack/rspack.config.ts + ].freeze + ALL_DEFAULT_CONFIG_CANDIDATES = (WEBPACK_DEFAULT_CONFIG_CANDIDATES + RSPACK_DEFAULT_CONFIG_CANDIDATES).freeze + private def resolved_package_json_path @@ -12,39 +24,77 @@ def resolved_package_json_path end def resolved_webpack_config_path - webpack_config_candidates.find { |path| File.exist?(path) } + webpack_config_candidates.find { |path| File.file?(path) } end def webpack_config_candidates candidates = [] + shakapacker_config_path = shakapacker_assets_bundler_config_path + candidates << shakapacker_config_path if shakapacker_config_path - shakapacker_config_dir = shakapacker_webpack_config_directory + shakapacker_config_dir = bundler_config_directory(shakapacker_config_path) if shakapacker_config_dir candidates.concat(%w[js ts cjs mjs].flat_map do |ext| + # Skip only the exact shakapacker path; still probe sibling + # standard-name configs (for example rspack when shakapacker points to + # webpack in the same directory). [ File.join(shakapacker_config_dir, "webpack.config.#{ext}"), File.join(shakapacker_config_dir, "rspack.config.#{ext}") - ] + ].reject { |path| path == shakapacker_config_path } end) end - candidates << "config/webpack/webpack.config.js" - candidates << "config/webpack/webpack.config.ts" - candidates << "config/rspack/rspack.config.js" - candidates << "config/rspack/rspack.config.ts" + # Default fallback candidates intentionally mirror generator defaults + # (`.js` / `.ts`), while `.cjs` / `.mjs` are probed only within resolved + # shakapacker config directories above. + candidates.concat(WEBPACK_DEFAULT_CONFIG_CANDIDATES) + candidates.concat(RSPACK_DEFAULT_CONFIG_CANDIDATES) candidates.uniq end - def shakapacker_webpack_config_directory + def shakapacker_assets_bundler_config_path + # Use instance_variable_defined? instead of ||= so nil results are cached + # and we do not retry require/rescue work on each call. + if instance_variable_defined?(:@shakapacker_assets_bundler_config_path) + return @shakapacker_assets_bundler_config_path + end + require "shakapacker" - path = Shakapacker.config.assets_bundler_config_path.to_s + @shakapacker_assets_bundler_config_path = normalize_shakapacker_assets_bundler_config_path( + Shakapacker.config.assets_bundler_config_path.to_s + ) + rescue LoadError, NameError + # Doctor/install checks should degrade gracefully when Shakapacker is + # missing; callers fall back to discovered default config candidates. + @shakapacker_assets_bundler_config_path = nil + rescue StandardError => e + message = "ReactOnRails could not read Shakapacker assets_bundler_config_path: #{e.class}: #{e.message}" + warn(message) unless Rails.logger + Rails.logger&.debug do + message + end + @shakapacker_assets_bundler_config_path = nil + end + + def normalize_shakapacker_assets_bundler_config_path(path) return nil if path.empty? - directory = File.dirname(path) rails_root = Rails.root.to_s - directory.start_with?("#{rails_root}/") ? directory.sub("#{rails_root}/", "") : directory - rescue LoadError, StandardError - nil + return path if rails_root.empty? || rails_root == "/" + + # NOTE: Prefix normalization assumes matching separators and does not + # normalize Windows-style `\` vs `/` path variants. + rails_root_prefix = "#{rails_root}/" + normalized_path = path.start_with?(rails_root_prefix) ? path.delete_prefix(rails_root_prefix) : path + normalized_path.empty? ? nil : normalized_path + end + + def bundler_config_directory(config_path) + return nil unless config_path + + directory = File.dirname(config_path) + directory == "." ? nil : directory end end end diff --git a/react_on_rails/lib/react_on_rails/configuration.rb b/react_on_rails/lib/react_on_rails/configuration.rb index b50f6377e8..17f42d7f64 100644 --- a/react_on_rails/lib/react_on_rails/configuration.rb +++ b/react_on_rails/lib/react_on_rails/configuration.rb @@ -29,6 +29,8 @@ def self.configure DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze DEFAULT_COMPONENT_REGISTRY_TIMEOUT = 5000 DEFAULT_SERVER_BUNDLE_OUTPUT_PATH = "ssr-generated" + DEFAULT_SERVER_RENDERER_POOL_SIZE = 1 + DEFAULT_SERVER_RENDERER_TIMEOUT_SECONDS = 20 def self.configuration @configuration ||= Configuration.new( @@ -44,8 +46,8 @@ def self.configuration raise_on_prerender_error: Rails.env.development?, trace: Rails.env.development?, development_mode: Rails.env.development?, - server_renderer_pool_size: 1, - server_renderer_timeout: 20, + server_renderer_pool_size: DEFAULT_SERVER_RENDERER_POOL_SIZE, + server_renderer_timeout: DEFAULT_SERVER_RENDERER_TIMEOUT_SECONDS, skip_display_none: nil, # skip_display_none is deprecated webpack_generated_files: %w[manifest.json], diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 79ae7d4ce1..2f69e05920 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -198,6 +198,11 @@ def check_development end def check_javascript_bundles + if server_bundle_filename.to_s.strip.empty? + checker.add_info("ℹ️ server_bundle_js_file is blank (SSR disabled), skipping SSR bundle existence check") + return + end + server_bundle_path = determine_server_bundle_path if File.exist?(server_bundle_path) checker.add_success("✅ Server bundle file exists at #{server_bundle_path}") @@ -628,11 +633,11 @@ def check_key_configuration_files webpack_config_path = resolved_webpack_config_path if webpack_config_path - checker.add_success("✅ Webpack configuration: #{webpack_config_path}") + checker.add_success("✅ Bundler configuration: #{webpack_config_path}") else - checker.add_warning("⚠️ Missing Webpack configuration: config/webpack/webpack.config.js") + checker.add_warning("⚠️ Missing bundler configuration: webpack/rspack config file not found") checker.add_info( - "ℹ️ If your app uses a custom webpack config location, this warning may be informational." + "ℹ️ Checked default config locations and Shakapacker assets_bundler_config_path, if available." ) end @@ -674,16 +679,18 @@ def check_server_rendering_engine checker.add_info("\n🖥️ Server Rendering Engine:") begin - uses_node_renderer = ReactOnRails::Utils.react_on_rails_pro? && - pro_initializer_has_node_renderer? + pro_renderer = resolved_pro_server_renderer + uses_node_renderer = pro_renderer == "NodeRenderer" if uses_node_renderer checker.add_info(" Pro uses NodeRenderer for server rendering") if defined?(ExecJS) && ExecJS.runtime checker.add_info(" ExecJS available as fallback: #{ExecJS.runtime.name}") - else + elsif pro_execjs_fallback_enabled? checker.add_warning(" ⚠️ ExecJS fallback is enabled but ExecJS is not available") checker.add_info(" 💡 Install mini_racer or set renderer_use_fallback_exec_js = false") + else + checker.add_info(" ℹ️ ExecJS fallback is disabled (renderer_use_fallback_exec_js = false)") end elsif defined?(ExecJS) runtime_name = ExecJS.runtime.name if ExecJS.runtime @@ -749,90 +756,160 @@ def check_react_on_rails_configuration_details def check_react_on_rails_initializer config_path = "config/initializers/react_on_rails.rb" + runtime_config = react_on_rails_runtime_configuration + initializer_exists = File.exist?(config_path) - unless File.exist?(config_path) + unless runtime_config || initializer_exists checker.add_warning("⚠️ React on Rails configuration file not found: #{config_path}") checker.add_info("💡 Run 'rails generate react_on_rails:install' to create configuration file") return end + if !initializer_exists && runtime_config + checker.add_info("ℹ️ No config/initializers/react_on_rails.rb found (using runtime configuration)") + end + begin - content = File.read(config_path) + content = initializer_exists ? File.read(config_path) : "" checker.add_info("📋 React on Rails Configuration:") checker.add_info("📍 Documentation: https://reactonrails.com/docs/configuration/") + if runtime_config + checker.add_info("ℹ️ Using loaded runtime configuration values") + else + checker.add_info("ℹ️ Using initializer parsing fallback (Rails environment unavailable)") + end # Analyze configuration settings - analyze_server_rendering_config(content) - analyze_performance_config(content) - analyze_development_config(content) - analyze_i18n_config(content) - analyze_component_loading_config(content) - analyze_custom_extensions(content) + analyze_server_rendering_config(content, runtime_config) + analyze_performance_config(content, runtime_config) + analyze_development_config(content, runtime_config) + analyze_i18n_config(content, runtime_config) + analyze_component_loading_config(content, runtime_config) + analyze_custom_extensions(content, runtime_config) rescue StandardError => e checker.add_warning("⚠️ Unable to read react_on_rails.rb: #{e.message}") end end - # rubocop:disable Metrics/CyclomaticComplexity - def analyze_server_rendering_config(content) + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def analyze_server_rendering_config(content, runtime_config = nil) checker.add_info("\n🖥️ Server Rendering:") - # Server bundle file - server_bundle_match = content.match(/config\.server_bundle_js_file\s*=\s*["']([^"']+)["']/) - if server_bundle_match - checker.add_info(" server_bundle_js_file: #{server_bundle_match[1]}") + if runtime_config + raw_server_bundle_value = runtime_config.server_bundle_js_file + server_bundle_value = + if raw_server_bundle_value.is_a?(String) + raw_server_bundle_value.strip + else + raw_server_bundle_value + end + if server_bundle_value.present? + checker.add_info(" server_bundle_js_file: #{server_bundle_value}") + elsif server_bundle_value.nil? + fallback_server_bundle = server_bundle_filename + checker.add_info(" server_bundle_js_file: #{fallback_server_bundle} (initializer/default)") + else + checker.add_info(" server_bundle_js_file: \"\" (disabled)") + end else - checker.add_info(" server_bundle_js_file: server-bundle.js (default)") + # Server bundle file + server_bundle_match = content.match(/config\.server_bundle_js_file\s*=\s*["']([^"']+)["']/) + if server_bundle_match + checker.add_info(" server_bundle_js_file: #{server_bundle_match[1]}") + else + checker.add_info(' server_bundle_js_file: "" (default, SSR disabled)') + end end # Server bundle output path - server_bundle_path_match = content.match(/config\.server_bundle_output_path\s*=\s*["']([^"']+)["']/) default_path = ReactOnRails::DEFAULT_SERVER_BUNDLE_OUTPUT_PATH - rails_bundle_path = server_bundle_path_match ? server_bundle_path_match[1] : default_path + rails_bundle_path = + if runtime_config + runtime_config.server_bundle_output_path || default_path + else + server_bundle_path_match = content.match(/config\.server_bundle_output_path\s*=\s*["']([^"']+)["']/) + server_bundle_path_match ? server_bundle_path_match[1] : default_path + end checker.add_info(" server_bundle_output_path: #{rails_bundle_path}") # Enforce private server bundles - enforce_private_match = content.match(/config\.enforce_private_server_bundles\s*=\s*([^\s\n,]+)/) - checker.add_info(" enforce_private_server_bundles: #{enforce_private_match[1]}") if enforce_private_match + if runtime_config + checker.add_info(" enforce_private_server_bundles: true") if runtime_config.enforce_private_server_bundles + else + enforce_private_match = content.match(/config\.enforce_private_server_bundles\s*=\s*([^\s\n,]+)/) + checker.add_info(" enforce_private_server_bundles: #{enforce_private_match[1]}") if enforce_private_match + end # Check Shakapacker integration and provide recommendations check_shakapacker_private_output_path(rails_bundle_path) - # RSC bundle file (Pro feature) - rsc_bundle_match = content.match(/config\.rsc_bundle_js_file\s*=\s*["']([^"']+)["']/) - if rsc_bundle_match - checker.add_info(" rsc_bundle_js_file: #{rsc_bundle_match[1]} (React Server Components - Pro)") + # RSC bundle file (Pro feature). Base runtime config does not expose this setting. + rsc_bundle_value = + if runtime_config && defined?(ReactOnRailsPro) + ReactOnRailsPro.configuration.rsc_bundle_js_file + else + rsc_bundle_match = content.match(/config\.rsc_bundle_js_file\s*=\s*["']([^"']+)["']/) + rsc_bundle_match ? rsc_bundle_match[1] : nil + end + if rsc_bundle_value.present? + checker.add_info(" rsc_bundle_js_file: #{rsc_bundle_value} (React Server Components - Pro)") end # Prerender setting - prerender_match = content.match(/config\.prerender\s*=\s*([^\s\n,]+)/) - prerender_value = prerender_match ? prerender_match[1] : "false (default)" + prerender_value = + if runtime_config + runtime_config.prerender + else + prerender_match = content.match(/config\.prerender\s*=\s*([^\s\n,]+)/) + prerender_match ? prerender_match[1] : "false (default)" + end checker.add_info(" prerender: #{prerender_value}") # Server renderer pool settings - pool_size_match = content.match(/config\.server_renderer_pool_size\s*=\s*([^\s\n,]+)/) - checker.add_info(" server_renderer_pool_size: #{pool_size_match[1]}") if pool_size_match + if runtime_config + # Default is 1; only report explicit non-default override. + checker.add_info(" server_renderer_pool_size: #{runtime_config.server_renderer_pool_size}") \ + if runtime_config.server_renderer_pool_size != ReactOnRails::DEFAULT_SERVER_RENDERER_POOL_SIZE + else + pool_size_match = content.match(/config\.server_renderer_pool_size\s*=\s*([^\s\n,]+)/) + checker.add_info(" server_renderer_pool_size: #{pool_size_match[1]}") if pool_size_match + end - timeout_match = content.match(/config\.server_renderer_timeout\s*=\s*([^\s\n,]+)/) - checker.add_info(" server_renderer_timeout: #{timeout_match[1]} seconds") if timeout_match + if runtime_config + # Default is 20 seconds; only report explicit non-default override. + checker.add_info(" server_renderer_timeout: #{runtime_config.server_renderer_timeout} seconds") \ + if runtime_config.server_renderer_timeout != ReactOnRails::DEFAULT_SERVER_RENDERER_TIMEOUT_SECONDS + else + timeout_match = content.match(/config\.server_renderer_timeout\s*=\s*([^\s\n,]+)/) + checker.add_info(" server_renderer_timeout: #{timeout_match[1]} seconds") if timeout_match + end # Error handling - raise_on_error_match = content.match(/config\.raise_on_prerender_error\s*=\s*([^\s\n,]+)/) - return unless raise_on_error_match - - checker.add_info(" raise_on_prerender_error: #{raise_on_error_match[1]}") + if runtime_config + checker.add_info(" raise_on_prerender_error: #{runtime_config.raise_on_prerender_error}") \ + if runtime_config.raise_on_prerender_error != Rails.env.development? + else + raise_on_error_match = content.match(/config\.raise_on_prerender_error\s*=\s*([^\s\n,]+)/) + checker.add_info(" raise_on_prerender_error: #{raise_on_error_match[1]}") if raise_on_error_match + end end - # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - # rubocop:disable Metrics/CyclomaticComplexity - def analyze_performance_config(content) + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def analyze_performance_config(content, runtime_config = nil) checker.add_info("\n⚡ Performance & Loading:") # Component loading strategy - loading_strategy_match = content.match(/config\.generated_component_packs_loading_strategy\s*=\s*:([^\s\n,]+)/) - if loading_strategy_match - strategy = loading_strategy_match[1] + strategy = + if runtime_config + runtime_config.generated_component_packs_loading_strategy&.to_s + else + loading_strategy_match = + content.match(/config\.generated_component_packs_loading_strategy\s*=\s*:([^\s\n,]+)/) + loading_strategy_match&.[](1) + end + if strategy checker.add_info(" generated_component_packs_loading_strategy: :#{strategy}") case strategy @@ -853,8 +930,12 @@ def analyze_performance_config(content) end # Auto load bundle - auto_load_match = content.match(/config\.auto_load_bundle\s*=\s*([^\s\n,]+)/) - checker.add_info(" auto_load_bundle: #{auto_load_match[1]}") if auto_load_match + if runtime_config + checker.add_info(" auto_load_bundle: true") if runtime_config.auto_load_bundle + else + auto_load_match = content.match(/config\.auto_load_bundle\s*=\s*([^\s\n,]+)/) + checker.add_info(" auto_load_bundle: #{auto_load_match[1]}") if auto_load_match + end # Deprecated immediate_hydration setting immediate_hydration_match = content.match(/config\.immediate_hydration\s*=\s*([^\s\n,]+)/) @@ -865,112 +946,179 @@ def analyze_performance_config(content) end # Component registry timeout - timeout_match = content.match(/config\.component_registry_timeout\s*=\s*([^\s\n,]+)/) - return unless timeout_match - - checker.add_info(" component_registry_timeout: #{timeout_match[1]}ms") + if runtime_config + # Default is 5000 ms; only report explicit non-default override. + checker.add_info(" component_registry_timeout: #{runtime_config.component_registry_timeout}ms") \ + if runtime_config.component_registry_timeout != ReactOnRails::DEFAULT_COMPONENT_REGISTRY_TIMEOUT + else + timeout_match = content.match(/config\.component_registry_timeout\s*=\s*([^\s\n,]+)/) + checker.add_info(" component_registry_timeout: #{timeout_match[1]}ms") if timeout_match + end end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - # rubocop:disable Metrics/AbcSize - def analyze_development_config(content) + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def analyze_development_config(content, runtime_config = nil) checker.add_info("\n🔧 Development & Debugging:") - # Development mode - dev_mode_match = content.match(/config\.development_mode\s*=\s*([^\s\n,]+)/) - if dev_mode_match - checker.add_info(" development_mode: #{dev_mode_match[1]}") - else - checker.add_info(" development_mode: Rails.env.development? (default)") - end - - # Trace setting - trace_match = content.match(/config\.trace\s*=\s*([^\s\n,]+)/) - if trace_match - checker.add_info(" trace: #{trace_match[1]}") + if runtime_config + # development_mode/trace default to Rails.env.development?, so only + # surface explicit runtime divergence from that environment-driven + # default. + checker.add_info(" development_mode: #{runtime_config.development_mode}") \ + if runtime_config.development_mode != Rails.env.development? + checker.add_info(" trace: #{runtime_config.trace}") \ + if runtime_config.trace != Rails.env.development? + # logging_on_server/replay_console default to true in all environments, + # so any non-truthy runtime value is worth surfacing. + unless runtime_config.logging_on_server + checker.add_info(" logging_on_server: #{runtime_config.logging_on_server.inspect}") + end + unless runtime_config.replay_console + checker.add_info(" replay_console: #{runtime_config.replay_console.inspect}") + end + if runtime_config.build_test_command.present? + checker.add_info(" build_test_command: #{runtime_config.build_test_command}") + end + if runtime_config.build_production_command.present? + checker.add_info(" build_production_command: #{runtime_config.build_production_command}") + end else - checker.add_info(" trace: Rails.env.development? (default)") - end + # Development mode + dev_mode_match = content.match(/config\.development_mode\s*=\s*([^\s\n,]+)/) + if dev_mode_match + checker.add_info(" development_mode: #{dev_mode_match[1]}") + else + checker.add_info(" development_mode: Rails.env.development? (default)") + end - # Logging - logging_match = content.match(/config\.logging_on_server\s*=\s*([^\s\n,]+)/) - logging_value = logging_match ? logging_match[1] : "true (default)" - checker.add_info(" logging_on_server: #{logging_value}") + # Trace setting + trace_match = content.match(/config\.trace\s*=\s*([^\s\n,]+)/) + if trace_match + checker.add_info(" trace: #{trace_match[1]}") + else + checker.add_info(" trace: Rails.env.development? (default)") + end - # Console replay - replay_match = content.match(/config\.replay_console\s*=\s*([^\s\n,]+)/) - replay_value = replay_match ? replay_match[1] : "true (default)" - checker.add_info(" replay_console: #{replay_value}") + # Logging + logging_match = content.match(/config\.logging_on_server\s*=\s*([^\s\n,]+)/) + logging_value = logging_match ? logging_match[1] : "true (default)" + checker.add_info(" logging_on_server: #{logging_value}") - # Build commands - build_test_match = content.match(/config\.build_test_command\s*=\s*["']([^"']+)["']/) - checker.add_info(" build_test_command: #{build_test_match[1]}") if build_test_match + # Console replay + replay_match = content.match(/config\.replay_console\s*=\s*([^\s\n,]+)/) + replay_value = replay_match ? replay_match[1] : "true (default)" + checker.add_info(" replay_console: #{replay_value}") - build_prod_match = content.match(/config\.build_production_command\s*=\s*["']([^"']+)["']/) - return unless build_prod_match + # Build commands + build_test_match = content.match(/config\.build_test_command\s*=\s*["']([^"']+)["']/) + checker.add_info(" build_test_command: #{build_test_match[1]}") if build_test_match - checker.add_info(" build_production_command: #{build_prod_match[1]}") + build_prod_match = content.match(/config\.build_production_command\s*=\s*["']([^"']+)["']/) + checker.add_info(" build_production_command: #{build_prod_match[1]}") if build_prod_match + end end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - def analyze_i18n_config(content) + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def analyze_i18n_config(content, runtime_config = nil) i18n_configs = [] - i18n_dir_match = content.match(/config\.i18n_dir\s*=\s*["']([^"']+)["']/) - i18n_configs << "i18n_dir: #{i18n_dir_match[1]}" if i18n_dir_match + if runtime_config + i18n_configs << "i18n_dir: #{runtime_config.i18n_dir}" if runtime_config.i18n_dir.present? + i18n_configs << "i18n_yml_dir: #{runtime_config.i18n_yml_dir}" if runtime_config.i18n_yml_dir.present? + if runtime_config.i18n_output_format.present? + i18n_configs << "i18n_output_format: #{runtime_config.i18n_output_format}" + end + else + i18n_dir_match = content.match(/config\.i18n_dir\s*=\s*["']([^"']+)["']/) + i18n_configs << "i18n_dir: #{i18n_dir_match[1]}" if i18n_dir_match - i18n_yml_dir_match = content.match(/config\.i18n_yml_dir\s*=\s*["']([^"']+)["']/) - i18n_configs << "i18n_yml_dir: #{i18n_yml_dir_match[1]}" if i18n_yml_dir_match + i18n_yml_dir_match = content.match(/config\.i18n_yml_dir\s*=\s*["']([^"']+)["']/) + i18n_configs << "i18n_yml_dir: #{i18n_yml_dir_match[1]}" if i18n_yml_dir_match - i18n_format_match = content.match(/config\.i18n_output_format\s*=\s*["']([^"']+)["']/) - i18n_configs << "i18n_output_format: #{i18n_format_match[1]}" if i18n_format_match + i18n_format_match = content.match(/config\.i18n_output_format\s*=\s*["']([^"']+)["']/) + i18n_configs << "i18n_output_format: #{i18n_format_match[1]}" if i18n_format_match + end return unless i18n_configs.any? checker.add_info("\n🌍 Internationalization:") i18n_configs.each { |config| checker.add_info(" #{config}") } end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - def analyze_component_loading_config(content) + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def analyze_component_loading_config(content, runtime_config = nil) component_configs = [] + filesystem_registry_enabled = false - components_subdir_match = content.match(/config\.components_subdirectory\s*=\s*["']([^"']+)["']/) - if components_subdir_match - component_configs << "components_subdirectory: #{components_subdir_match[1]}" - checker.add_info(" ℹ️ File-system based component registry enabled") - end + if runtime_config + if runtime_config.components_subdirectory.present? + component_configs << "components_subdirectory: #{runtime_config.components_subdirectory}" + filesystem_registry_enabled = true + end + # Default is false; only report explicit non-default override. + if runtime_config.same_bundle_for_client_and_server + component_configs << "same_bundle_for_client_and_server: #{runtime_config.same_bundle_for_client_and_server}" + end + # Default is true; only report explicit non-default override. + component_configs << "random_dom_id: #{runtime_config.random_dom_id}" if runtime_config.random_dom_id == false + else + components_subdir_match = content.match(/config\.components_subdirectory\s*=\s*["']([^"']+)["']/) + if components_subdir_match + component_configs << "components_subdirectory: #{components_subdir_match[1]}" + filesystem_registry_enabled = true + end - same_bundle_match = content.match(/config\.same_bundle_for_client_and_server\s*=\s*([^\s\n,]+)/) - component_configs << "same_bundle_for_client_and_server: #{same_bundle_match[1]}" if same_bundle_match + same_bundle_match = content.match(/config\.same_bundle_for_client_and_server\s*=\s*([^\s\n,]+)/) + component_configs << "same_bundle_for_client_and_server: #{same_bundle_match[1]}" if same_bundle_match - random_dom_match = content.match(/config\.random_dom_id\s*=\s*([^\s\n,]+)/) - component_configs << "random_dom_id: #{random_dom_match[1]}" if random_dom_match + random_dom_match = content.match(/config\.random_dom_id\s*=\s*([^\s\n,]+)/) + component_configs << "random_dom_id: #{random_dom_match[1]}" if random_dom_match + end return unless component_configs.any? checker.add_info("\n📦 Component Loading:") component_configs.each { |config| checker.add_info(" #{config}") } + checker.add_info(" ℹ️ File-system based component registry enabled") if filesystem_registry_enabled end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity - def analyze_custom_extensions(content) - # Check for rendering extension - if /config\.rendering_extension\s*=\s*([^\s\n,]+)/.match?(content) - checker.add_info("\n🔌 Custom Extensions:") - checker.add_info(" rendering_extension: Custom rendering logic detected") - checker.add_info(" ℹ️ See: https://reactonrails.com/docs/configuration/#rendering_extension") - end + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def analyze_custom_extensions(content, runtime_config = nil) + extension_messages = [] + has_rendering_extension = false + + if runtime_config + has_rendering_extension = runtime_config.rendering_extension.present? + if runtime_config.rendering_props_extension.present? + extension_messages << " rendering_props_extension: Custom props logic detected" + end + if runtime_config.server_render_method.present? + extension_messages << " server_render_method: #{runtime_config.server_render_method}" + end + else + has_rendering_extension = /config\.rendering_extension\s*=\s*([^\s\n,]+)/.match?(content) + if /config\.rendering_props_extension\s*=\s*([^\s\n,]+)/.match?(content) + extension_messages << " rendering_props_extension: Custom props logic detected" + end - # Check for rendering props extension - if /config\.rendering_props_extension\s*=\s*([^\s\n,]+)/.match?(content) - checker.add_info(" rendering_props_extension: Custom props logic detected") + server_method_match = content.match(/config\.server_render_method\s*=\s*["']([^"']+)["']/) + extension_messages << " server_render_method: #{server_method_match[1]}" if server_method_match end - # Check for server render method - server_method_match = content.match(/config\.server_render_method\s*=\s*["']([^"']+)["']/) - return unless server_method_match + return unless has_rendering_extension || extension_messages.any? - checker.add_info(" server_render_method: #{server_method_match[1]}") + checker.add_info("\n🔌 Custom Extensions:") + if has_rendering_extension + checker.add_info(" rendering_extension: Custom rendering logic detected") + checker.add_info(" ℹ️ See: https://reactonrails.com/docs/configuration/#rendering_extension") + end + extension_messages.each { |msg| checker.add_info(msg) } end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def check_deprecated_configuration_settings return unless File.exist?("config/initializers/react_on_rails.rb") @@ -1194,6 +1342,13 @@ def determine_server_bundle_path end def server_bundle_filename + runtime_config = react_on_rails_runtime_configuration + if runtime_config + configured_value = runtime_config.server_bundle_js_file + # A blank runtime value intentionally disables SSR bundle checks; only nil falls back. + return configured_value unless configured_value.nil? + end + # Try to read from React on Rails initializer initializer_path = "config/initializers/react_on_rails.rb" if File.exist?(initializer_path) @@ -1222,20 +1377,32 @@ def exit_with_status # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def check_server_bundle_prerender_consistency config_path = "config/initializers/react_on_rails.rb" - return unless File.exist?(config_path) + runtime_config = react_on_rails_runtime_configuration + return unless runtime_config || File.exist?(config_path) checker.add_info("\n🔍 Server Rendering Consistency:") begin - content = File.read(config_path) + if runtime_config + server_bundle_value = runtime_config.server_bundle_js_file + server_bundle_set = + if server_bundle_value.nil? + server_bundle_filename.to_s.strip.present? + else + server_bundle_value.present? + end + prerender_set = runtime_config.prerender + else + content = File.read(config_path) - # Check for server bundle configuration - server_bundle_match = content.match(/config\.server_bundle_js_file\s*=\s*["']([^"']+)["']/) - server_bundle_set = server_bundle_match && server_bundle_match[1].present? + # Check for server bundle configuration + server_bundle_match = content.match(/config\.server_bundle_js_file\s*=\s*["']([^"']+)["']/) + server_bundle_set = server_bundle_match && server_bundle_match[1].present? - # Check for global prerender setting - prerender_match = content.match(/config\.prerender\s*=\s*(true)/) - prerender_set = prerender_match + # Check for global prerender setting + prerender_match = content.match(/config\.prerender\s*=\s*(true)/) + prerender_set = prerender_match + end # Check if prerender is used in views uses_prerender = uses_prerender_in_views? @@ -2214,11 +2381,64 @@ def ensure_rails_environment_loaded checker.add_warning(<<~MSG.strip) ⚠️ Could not load Rails environment: #{e.message} - Pro/RSC diagnostics may reflect default values instead of your app's configuration. + Configuration diagnostics may reflect default values instead of your app's runtime configuration. MSG false end + def react_on_rails_runtime_configuration + return @react_on_rails_runtime_configuration if defined?(@react_on_rails_runtime_configuration) + + @react_on_rails_runtime_configuration = + ensure_rails_environment_loaded ? ReactOnRails.configuration : nil + rescue StandardError, LoadError => e + checker.add_warning("⚠️ Could not query React on Rails runtime configuration: #{e.message}") + # Memoize as nil to avoid repeated failed lookups on subsequent checks. + @react_on_rails_runtime_configuration = nil + end + + def resolved_pro_server_renderer + return @resolved_pro_server_renderer if defined?(@resolved_pro_server_renderer) + return (@resolved_pro_server_renderer = nil) unless ReactOnRails::Utils.react_on_rails_pro? + + rails_environment_loaded = ensure_rails_environment_loaded + @resolved_pro_server_renderer = + if rails_environment_loaded && defined?(ReactOnRailsPro) + # server_renderer is stored as a plain string in Pro config (for example, "NodeRenderer"). + ReactOnRailsPro.configuration.server_renderer.to_s + elsif pro_initializer_has_node_renderer? + "NodeRenderer" + elsif rails_environment_loaded + checker.add_warning( + "⚠️ Could not determine Pro server renderer: ReactOnRailsPro is unavailable " \ + "and no initializer match found." + ) + nil + else + checker.add_info( + "ℹ️ Could not determine Pro server renderer: Rails environment unavailable and no initializer match found." + ) + nil + end + rescue StandardError, LoadError => e + checker.add_warning("⚠️ Could not read Pro runtime renderer configuration: #{e.message}") + @resolved_pro_server_renderer = nil + end + + def pro_execjs_fallback_enabled? + return ReactOnRailsPro.configuration.renderer_use_fallback_exec_js if defined?(ReactOnRailsPro) + + config_path = "config/initializers/react_on_rails_pro.rb" + return true unless File.exist?(config_path) + + content = File.read(config_path) + fallback_match = content.match(/config\.renderer_use_fallback_exec_js\s*=\s*(true|false)/) + fallback_match ? fallback_match[1] == "true" : true + rescue StandardError, LoadError => e + checker.add_warning("⚠️ Could not read Pro fallback ExecJS configuration: #{e.message}") + true + end + # Resolve the JavaScript source path from Shakapacker config. # Falls back to "app/javascript" if Shakapacker is not available. def resolve_js_source_path diff --git a/react_on_rails/lib/react_on_rails/system_checker.rb b/react_on_rails/lib/react_on_rails/system_checker.rb index 498d9f415c..a48059f6ac 100644 --- a/react_on_rails/lib/react_on_rails/system_checker.rb +++ b/react_on_rails/lib/react_on_rails/system_checker.rb @@ -141,6 +141,7 @@ def check_shakapacker_configuration • config/shakapacker.yml • config/webpack/webpack.config.{js,ts} • config/rspack/rspack.config.{js,ts} + • assets_bundler_config_path target declared by Shakapacker (when configured) Run: bundle exec rails shakapacker:install MSG @@ -308,40 +309,29 @@ def check_webpack_configuration 🚫 Bundler configuration not found. Expected one of: config/webpack/webpack.config.{js,ts} or config/rspack/rspack.config.{js,ts} + Also checks Shakapacker's configured assets_bundler_config_path when available. Run: rails generate react_on_rails:install MSG end end def detect_bundler_config_path - paths_by_bundler = { - "rspack" => existing_bundler_config_paths("rspack"), - "webpack" => existing_bundler_config_paths("webpack") - } - - present_paths = paths_by_bundler.select { |_bundler, paths| paths.any? } - return nil if present_paths.empty? - return present_paths.values.first.first if present_paths.one? + resolved_config_path = resolved_webpack_config_path + return nil unless resolved_config_path + # Explicit shakapacker assets_bundler_config_path matches are treated as + # authoritative and intentionally bypass cross-bundler ambiguity warnings. + return resolved_config_path if explicit_shakapacker_bundler_config_path?(resolved_config_path) - configured_bundler = configured_assets_bundler - if configured_bundler && paths_by_bundler[configured_bundler].any? - add_warning( - "⚠️ Found both webpack and rspack configs. Using #{configured_bundler} from config/shakapacker.yml." - ) - return paths_by_bundler[configured_bundler].first - end - - # Default to webpack when shakapacker.yml doesn't declare assets_bundler. - # Webpack is the longer-established default; rspack users typically set - # assets_bundler explicitly in shakapacker.yml. - add_warning( - "⚠️ Found both webpack and rspack configs. Could not determine active bundler; defaulting to webpack." - ) - paths_by_bundler["webpack"].first || paths_by_bundler["rspack"].first + # Re-scan candidate configs by bundler so we can emit clear warnings when + # both webpack and rspack configs exist in the project. If a future + # candidate falls outside the `.config.*` naming convention used + # by `existing_bundler_config_paths`, fall back to the originally + # discovered file. + resolve_default_bundler_config_path || resolved_config_path end def suggest_webpack_inspection(config_path) - bundler_name = config_path.include?("rspack") ? "rspack" : "webpack" + bundler_name = bundler_name_for_config_path(config_path) export_style = config_path.end_with?(".ts") ? "export default" : "module.exports" add_info("💡 To debug #{bundler_name} builds:") @@ -393,7 +383,7 @@ def bundle_analyzer_available? def check_webpack_config_content(config_path) content = File.read(config_path) - bundler_name = config_path.include?("rspack") ? "rspack" : "webpack" + bundler_name = bundler_name_for_config_path(config_path) if react_on_rails_config?(content) add_success("✅ #{bundler_name.capitalize} config includes React on Rails environment configuration") @@ -474,10 +464,12 @@ def shakapacker_configured? end def bundler_config_file_exists? - File.exist?("config/webpack/webpack.config.js") || - File.exist?("config/webpack/webpack.config.ts") || - File.exist?("config/rspack/rspack.config.js") || - File.exist?("config/rspack/rspack.config.ts") + # Fast path for common generator-default layouts (.js/.ts only). If none + # of these paths exist, we fall back to full candidate probing, which can + # include .cjs/.mjs variants in shakapacker-derived directories. + return true if ALL_DEFAULT_CONFIG_CANDIDATES.any? { |path| File.file?(path) } + + !resolved_webpack_config_path.nil? end def shakapacker_in_gemfile? @@ -514,19 +506,83 @@ def normalize_config_content(content) .strip end + def resolve_default_bundler_config_path + paths_by_bundler = { + "rspack" => existing_bundler_config_paths("rspack"), + "webpack" => existing_bundler_config_paths("webpack") + } + + present_paths = paths_by_bundler.select { |_bundler, paths| paths.any? } + return nil if present_paths.empty? + return present_paths.values.first.first if present_paths.one? + + configured_bundler = configured_assets_bundler + if configured_bundler && paths_by_bundler[configured_bundler].any? + return warn_and_pick_configured_bundler_path(paths_by_bundler, configured_bundler) + end + + # Default to webpack when shakapacker.yml doesn't declare assets_bundler. + # Webpack is the longer-established default; rspack users typically set + # assets_bundler explicitly in shakapacker.yml. + add_warning( + "⚠️ Found both webpack and rspack configs. Could not determine active bundler; defaulting to webpack." + ) + paths_by_bundler["webpack"].first || paths_by_bundler["rspack"].first + end + + def warn_and_pick_configured_bundler_path(paths_by_bundler, configured_bundler) + add_warning( + "⚠️ Found both webpack and rspack configs. Using #{configured_bundler} from config/shakapacker.yml." + ) + paths_by_bundler[configured_bundler].first + end + def existing_bundler_config_paths(bundler) - candidate_paths = if bundler == "rspack" - %w[ - config/rspack/rspack.config.ts - config/rspack/rspack.config.js - ] - else - %w[ - config/webpack/webpack.config.ts - config/webpack/webpack.config.js - ] - end - candidate_paths.select { |path| File.exist?(path) } + # This runs only after detect_bundler_config_path has ruled out an exact + # explicit shakapacker path match, so directory-derived fallback + # candidates are intentionally considered here. + # Custom shakapacker config paths (non-standard basenames) are handled + # earlier via explicit_shakapacker_bundler_config_path? and intentionally + # excluded from this `.config.*` classifier. + # webpack_config_candidates may consult Shakapacker config paths, but that + # lookup is memoized by shakapacker_assets_bundler_config_path. + bundler_prefix = "#{bundler}.config." + webpack_config_candidates.select do |path| + next false unless File.file?(path) + + File.basename(path).start_with?(bundler_prefix) + end + end + + def bundler_name_for_config_path(config_path) + return inferred_bundler_name(config_path) unless explicit_shakapacker_bundler_config_path?(config_path) + + configured_bundler = configured_assets_bundler + return configured_bundler if configured_bundler + + # For explicit custom shakapacker paths without assets_bundler in + # shakapacker.yml, fallback inference uses path heuristics and can + # misclassify non-standard filenames. + add_inferred_bundler_notice_once + inferred_bundler_name(config_path) + end + + def inferred_bundler_name(config_path) + # Heuristic only: paths containing "rspack" are treated as rspack and all + # others as webpack. This is accurate for standard generator-created paths. + config_path.include?("rspack") ? "rspack" : "webpack" + end + + def explicit_shakapacker_bundler_config_path?(resolved_config_path) + shakapacker_path = shakapacker_assets_bundler_config_path + !shakapacker_path.nil? && shakapacker_path == resolved_config_path + end + + def add_inferred_bundler_notice_once + return if @inferred_bundler_notice_shown + + add_info("ℹ️ assets_bundler not set in config/shakapacker.yml; inferring bundler from config path name.") + @inferred_bundler_notice_shown = true end def configured_assets_bundler diff --git a/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb b/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb index 47ccbca910..9469c6eaf0 100644 --- a/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb +++ b/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb @@ -84,6 +84,202 @@ end end + describe "#check_react_on_rails_initializer" do + let(:doctor) { described_class.new(verbose: false, fix: false) } + let(:checker) { doctor.instance_variable_get(:@checker) } + let(:runtime_config) do + instance_double( + ReactOnRails::Configuration, + server_bundle_js_file: "runtime-server-bundle.js", + server_bundle_output_path: "runtime-ssr-output", + enforce_private_server_bundles: true, + prerender: true, + server_renderer_pool_size: 3, + server_renderer_timeout: 45, + raise_on_prerender_error: true, + generated_component_packs_loading_strategy: :async, + auto_load_bundle: true, + component_registry_timeout: 7000, + development_mode: false, + trace: false, + logging_on_server: false, + replay_console: false, + build_test_command: "RAILS_ENV=test bin/shakapacker", + build_production_command: "RAILS_ENV=production bin/shakapacker", + i18n_dir: nil, + i18n_yml_dir: nil, + i18n_output_format: nil, + components_subdirectory: nil, + same_bundle_for_client_and_server: false, + random_dom_id: nil, + rendering_extension: nil, + rendering_props_extension: nil, + server_render_method: nil + ) + end + + around do |example| + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) { example.run } + end + end + + def write_project_file(path, content) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + it "prefers runtime values over initializer regex parsing" do + write_project_file("config/initializers/react_on_rails.rb", <<~RUBY) + ReactOnRails.configure do |config| + config.server_bundle_js_file = "initializer-server-bundle.js" + config.prerender = false + config.auto_load_bundle = false + end + RUBY + + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + + expect(info_messages).to include(a_string_including("Using loaded runtime configuration values")) + expect(info_messages).to include(a_string_including("server_bundle_js_file: runtime-server-bundle.js")) + expect(info_messages).to include(a_string_including("prerender: true")) + expect(info_messages).to include(a_string_including("auto_load_bundle: true")) + expect(info_messages).not_to include(a_string_including("random_dom_id:")) + expect(info_messages).not_to include(a_string_including("initializer-server-bundle.js")) + end + + it "uses runtime values when initializer file is missing" do + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + warning_messages = checker.messages.select { |msg| msg[:type] == :warning }.map { |msg| msg[:content] } + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + + expect(warning_messages).not_to include( + a_string_including("React on Rails configuration file not found: config/initializers/react_on_rails.rb") + ) + expect(info_messages).to include( + a_string_including("No config/initializers/react_on_rails.rb found (using runtime configuration)") + ) + expect(info_messages).to include(a_string_including("Using loaded runtime configuration values")) + expect(info_messages).to include(a_string_including("server_bundle_js_file: runtime-server-bundle.js")) + end + + it "shows initializer/default bundle value when runtime server_bundle_js_file is nil" do + allow(runtime_config).to receive(:server_bundle_js_file).and_return(nil) + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).to include( + a_string_including("server_bundle_js_file: server-bundle.js (initializer/default)") + ) + end + + it "treats whitespace-only runtime server_bundle_js_file as disabled" do + allow(runtime_config).to receive(:server_bundle_js_file).and_return(" ") + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).to include( + a_string_including('server_bundle_js_file: "" (disabled)') + ) + end + + it "shows SSR-disabled default when runtime config is unavailable and initializer does not set server bundle" do + write_project_file("config/initializers/react_on_rails.rb", <<~RUBY) + ReactOnRails.configure do |config| + config.prerender = false + end + RUBY + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(nil) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).to include( + a_string_including('server_bundle_js_file: "" (default, SSR disabled)') + ) + end + + it "omits component_registry_timeout when runtime value is the default" do + allow(runtime_config).to receive(:component_registry_timeout).and_return(5000) + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).not_to include(a_string_including("component_registry_timeout")) + end + + it "omits auto_load_bundle when runtime value is the default" do + allow(runtime_config).to receive(:auto_load_bundle).and_return(false) + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).not_to include(a_string_including("auto_load_bundle")) + end + + it "omits development/debugging runtime values when they match defaults" do + allow(runtime_config).to receive_messages( + development_mode: Rails.env.development?, + trace: Rails.env.development?, + logging_on_server: true, + replay_console: true + ) + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).not_to include(a_string_including("development_mode")) + expect(info_messages).not_to include(a_string_including("trace")) + expect(info_messages).not_to include(a_string_including("logging_on_server")) + expect(info_messages).not_to include(a_string_including("replay_console")) + end + + it "reports nil logging/replay values explicitly when runtime config is unexpected" do + allow(runtime_config).to receive_messages(logging_on_server: nil, replay_console: nil) + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).to include(a_string_including("logging_on_server: nil")) + expect(info_messages).to include(a_string_including("replay_console: nil")) + end + + it "omits enforce_private_server_bundles when runtime value is the default" do + allow(runtime_config).to receive(:enforce_private_server_bundles).and_return(false) + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).not_to include(a_string_including("enforce_private_server_bundles")) + end + + it "omits raise_on_prerender_error when runtime value matches environment default" do + allow(runtime_config).to receive(:raise_on_prerender_error).and_return(Rails.env.development?) + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_react_on_rails_initializer) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).not_to include(a_string_including("raise_on_prerender_error")) + end + end + describe "server bundle path detection" do let(:doctor) { described_class.new } @@ -162,12 +358,44 @@ end describe "#server_bundle_filename" do + context "when runtime config sets server_bundle_js_file to an empty string" do + let(:runtime_config) do + instance_double(ReactOnRails::Configuration, server_bundle_js_file: "") + end + + before do + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + end + + it "returns an empty string" do + filename = doctor.send(:server_bundle_filename) + expect(filename).to eq("") + end + end + + context "when runtime config has nil server_bundle_js_file" do + let(:runtime_config) do + instance_double(ReactOnRails::Configuration, server_bundle_js_file: nil) + end + + before do + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + allow(File).to receive(:exist?).with("config/initializers/react_on_rails.rb").and_return(false) + end + + it "falls back to the default filename" do + filename = doctor.send(:server_bundle_filename) + expect(filename).to eq("server-bundle.js") + end + end + context "when react_on_rails.rb has custom filename" do let(:initializer_content) do 'config.server_bundle_js_file = "custom-server-bundle.js"' end before do + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(nil) allow(File).to receive(:exist?).with("config/initializers/react_on_rails.rb").and_return(true) allow(File).to receive(:read).with("config/initializers/react_on_rails.rb").and_return(initializer_content) end @@ -180,6 +408,7 @@ context "when no custom filename is configured" do before do + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(nil) allow(File).to receive(:exist?).with("config/initializers/react_on_rails.rb").and_return(false) end @@ -191,6 +420,23 @@ end end + describe "#check_javascript_bundles" do + let(:doctor) { described_class.new(verbose: false, fix: false) } + let(:checker) { doctor.instance_variable_get(:@checker) } + + it "treats whitespace server_bundle_js_file as disabled SSR" do + allow(doctor).to receive(:server_bundle_filename).and_return(" ") + + doctor.send(:check_javascript_bundles) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + warning_messages = checker.messages.select { |msg| msg[:type] == :warning }.map { |msg| msg[:content] } + + expect(info_messages).to include(a_string_including("skipping SSR bundle existence check")) + expect(warning_messages).to be_empty + end + end + describe "#check_async_usage" do let(:checker) { instance_double(ReactOnRails::SystemChecker) } @@ -1103,6 +1349,79 @@ def write_project_file(path, content) File.write(path, content) end + context "when runtime config conflicts with initializer text" do + let(:runtime_config) do + instance_double( + ReactOnRails::Configuration, + server_bundle_js_file: "", + prerender: true + ) + end + + it "uses runtime values and reports missing server bundle" do + write_project_file("config/initializers/react_on_rails.rb", <<~RUBY) + ReactOnRails.configure do |config| + config.server_bundle_js_file = "server-bundle.js" + config.prerender = false + end + RUBY + + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_server_bundle_prerender_consistency) + + warning_messages = checker.messages.select { |msg| msg[:type] == :warning }.map { |msg| msg[:content] } + expect(warning_messages).to include( + a_string_including("Server rendering is enabled but server_bundle_js_file is not configured") + ) + end + end + + context "when initializer is missing but runtime config is available" do + let(:runtime_config) do + instance_double( + ReactOnRails::Configuration, + server_bundle_js_file: "", + prerender: true + ) + end + + it "still performs the consistency check" do + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_server_bundle_prerender_consistency) + + warning_messages = checker.messages.select { |msg| msg[:type] == :warning }.map { |msg| msg[:content] } + expect(warning_messages).to include( + a_string_including("Server rendering is enabled but server_bundle_js_file is not configured") + ) + end + end + + context "when runtime server_bundle_js_file is nil and prerender is enabled" do + let(:runtime_config) do + instance_double( + ReactOnRails::Configuration, + server_bundle_js_file: nil, + prerender: true + ) + end + + it "treats nil as initializer/default fallback instead of disabled" do + allow(doctor).to receive(:react_on_rails_runtime_configuration).and_return(runtime_config) + + doctor.send(:check_server_bundle_prerender_consistency) + + warning_messages = checker.messages.select { |msg| msg[:type] == :warning }.map { |msg| msg[:content] } + success_messages = checker.messages.select { |msg| msg[:type] == :success }.map { |msg| msg[:content] } + + expect(warning_messages).not_to include( + a_string_including("Server rendering is enabled but server_bundle_js_file is not configured") + ) + expect(success_messages).to include(a_string_including("Server rendering configuration is consistent")) + end + end + context "when views use stream_react_component (RSC/streaming apps)" do it "reports consistent configuration" do write_project_file("config/initializers/react_on_rails.rb", <<~RUBY) @@ -1198,6 +1517,24 @@ def write_project_file(path, content) File.write(path, content) end + context "when Pro runtime config reports NodeRenderer without initializer text" do + before do + allow(doctor).to receive(:resolved_pro_server_renderer).and_return("NodeRenderer") + end + + it "treats ExecJS as fallback without rechecking Pro availability" do + stub_const("ExecJS", execjs_module) + expect(ReactOnRails::Utils).not_to receive(:react_on_rails_pro?) + + doctor.send(:check_server_rendering_engine) + + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] } + expect(info_messages).to include(a_string_including("Pro uses NodeRenderer")) + expect(info_messages).to include(a_string_including("ExecJS available as fallback")) + expect(info_messages).not_to include(a_string_matching(/^\s+ExecJS Runtime:/)) + end + end + context "when Pro initializer has NodeRenderer" do before do allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) @@ -1243,6 +1580,33 @@ def write_project_file(path, content) end end + context "when Pro initializer has NodeRenderer, ExecJS is absent, and fallback is disabled" do + before do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + write_project_file("config/initializers/react_on_rails_pro.rb", <<~RUBY) + ReactOnRailsPro.configure do |config| + config.server_renderer = "NodeRenderer" + config.renderer_use_fallback_exec_js = false + end + RUBY + hide_const("ExecJS") + end + + it "does not warn about missing ExecJS fallback" do + doctor.send(:check_server_rendering_engine) + + info_messages = checker.messages.select { |m| m[:type] == :info }.map { |m| m[:content] } + warning_messages = checker.messages.select { |m| m[:type] == :warning }.map { |m| m[:content] } + expect(info_messages).to include(a_string_including("Pro uses NodeRenderer")) + expect(info_messages).to include( + a_string_including("ExecJS fallback is disabled (renderer_use_fallback_exec_js = false)") + ) + expect(warning_messages).not_to include( + a_string_including("ExecJS fallback is enabled but ExecJS is not available") + ) + end + end + context "when Pro initializer does not have NodeRenderer" do before do allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) @@ -1502,36 +1866,72 @@ def write_project_file(path, content) describe "private path resolution helpers" do describe "#resolved_webpack_config_path" do - it "prefers shakapacker-derived webpack config candidates over the default path" do - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with("config/custom/webpack.config.ts").and_return(true) - allow(doctor).to receive(:shakapacker_webpack_config_directory).and_return("config/custom") + it "prioritizes shakapacker's exact assets_bundler_config_path" do + allow(File).to receive(:file?).and_return(false) + allow(File).to receive(:file?).with("config/custom/custom-bundler.config.js").and_return(true) + allow(File).to receive(:file?).with("config/custom/webpack.config.js").and_return(true) + allow(doctor).to receive(:shakapacker_assets_bundler_config_path) + .and_return("config/custom/custom-bundler.config.js") + allow(doctor).to receive(:bundler_config_directory) + .with("config/custom/custom-bundler.config.js") + .and_return("config/custom") + + expect(doctor.send(:resolved_webpack_config_path)).to eq("config/custom/custom-bundler.config.js") + end + + it "falls back to shakapacker-derived webpack config candidates when exact shakapacker path is not a file" do + allow(File).to receive(:file?).and_return(false) + allow(File).to receive(:file?).with("config/custom/webpack.config.ts").and_return(true) + allow(doctor).to receive(:bundler_config_directory) + .with("config/custom/custom-bundler.config.js") + .and_return("config/custom") + allow(doctor).to receive(:shakapacker_assets_bundler_config_path) + .and_return("config/custom/custom-bundler.config.js") expect(doctor.send(:resolved_webpack_config_path)).to eq("config/custom/webpack.config.ts") end + it "keeps sibling bundler candidates when shakapacker path uses a standard filename" do + allow(File).to receive(:file?).and_return(false) + allow(File).to receive(:file?).with("config/webpack/rspack.config.js").and_return(true) + allow(doctor).to receive(:bundler_config_directory) + .with("config/webpack/webpack.config.js") + .and_return("config/webpack") + allow(doctor).to receive(:shakapacker_assets_bundler_config_path) + .and_return("config/webpack/webpack.config.js") + + expect(doctor.send(:resolved_webpack_config_path)).to eq("config/webpack/rspack.config.js") + end + it "resolves rspack config candidates from the shakapacker-derived directory" do - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with("config/rspack/rspack.config.ts").and_return(true) - allow(doctor).to receive(:shakapacker_webpack_config_directory).and_return("config/rspack") + allow(File).to receive(:file?).and_return(false) + allow(File).to receive(:file?).with("config/rspack/rspack.config.ts").and_return(true) + allow(doctor).to receive(:bundler_config_directory) + .with("config/rspack/custom-bundler.config.js") + .and_return("config/rspack") + allow(doctor).to receive(:shakapacker_assets_bundler_config_path) + .and_return("config/rspack/custom-bundler.config.js") expect(doctor.send(:resolved_webpack_config_path)).to eq("config/rspack/rspack.config.ts") end it "falls back to default rspack config paths when shakapacker directory is unavailable" do - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with("config/rspack/rspack.config.js").and_return(true) - allow(doctor).to receive(:shakapacker_webpack_config_directory).and_return(nil) + allow(File).to receive(:file?).and_return(false) + allow(File).to receive(:file?).with("config/rspack/rspack.config.js").and_return(true) + allow(doctor).to receive(:bundler_config_directory).with(nil).and_return(nil) + allow(doctor).to receive(:shakapacker_assets_bundler_config_path).and_return(nil) expect(doctor.send(:resolved_webpack_config_path)).to eq("config/rspack/rspack.config.js") end end - describe "#shakapacker_webpack_config_directory" do - it "extracts a directory from shakapacker's config file path" do + describe "#shakapacker_assets_bundler_config_path" do + it "normalizes shakapacker assets_bundler_config_path to a rails-relative path" do + rails_root = Pathname.new("/tmp/myapp") + allow(Rails).to receive(:root).and_return(rails_root) allow(doctor).to receive(:require).with("shakapacker").and_return(true) shakapacker_config = Struct.new(:assets_bundler_config_path).new( - "#{Rails.root}/config/custom/webpack.config.ts" + "#{rails_root}/config/custom/custom-bundler.config.ts" ) shakapacker_class = Class.new do class << self @@ -1541,7 +1941,65 @@ class << self stub_const("Shakapacker", shakapacker_class) Shakapacker.config = shakapacker_config - expect(doctor.send(:shakapacker_webpack_config_directory)).to eq("config/custom") + expect(doctor.send(:shakapacker_assets_bundler_config_path)).to eq("config/custom/custom-bundler.config.ts") + end + + it "keeps absolute assets_bundler_config_path outside Rails.root unchanged" do + rails_root = Pathname.new("/tmp/myapp") + allow(Rails).to receive(:root).and_return(rails_root) + allow(doctor).to receive(:require).with("shakapacker").and_return(true) + shakapacker_config = Struct.new(:assets_bundler_config_path).new("/opt/custom/bundler.config.js") + shakapacker_class = Class.new do + class << self + attr_accessor :config + end + end + stub_const("Shakapacker", shakapacker_class) + Shakapacker.config = shakapacker_config + + expect(doctor.send(:shakapacker_assets_bundler_config_path)).to eq("/opt/custom/bundler.config.js") + end + + it "does not strip absolute paths when Rails.root is filesystem root" do + allow(Rails).to receive(:root).and_return(Pathname.new("/")) + allow(doctor).to receive(:require).with("shakapacker").and_return(true) + shakapacker_config = Struct.new(:assets_bundler_config_path).new("/opt/custom/bundler.config.js") + shakapacker_class = Class.new do + class << self + attr_accessor :config + end + end + stub_const("Shakapacker", shakapacker_class) + Shakapacker.config = shakapacker_config + + expect(doctor.send(:shakapacker_assets_bundler_config_path)).to eq("/opt/custom/bundler.config.js") + end + + it "returns nil when normalization strips to an empty relative path" do + rails_root = Pathname.new("/tmp/myapp") + allow(Rails).to receive(:root).and_return(rails_root) + allow(doctor).to receive(:require).with("shakapacker").and_return(true) + shakapacker_config = Struct.new(:assets_bundler_config_path).new("#{rails_root}/") + shakapacker_class = Class.new do + class << self + attr_accessor :config + end + end + stub_const("Shakapacker", shakapacker_class) + Shakapacker.config = shakapacker_config + + expect(doctor.send(:shakapacker_assets_bundler_config_path)).to be_nil + end + end + + describe "#bundler_config_directory" do + it "extracts a directory from shakapacker's config file path" do + expect(doctor.send(:bundler_config_directory, "config/custom/webpack.config.ts")) + .to eq("config/custom") + end + + it "returns nil for bare filenames without a directory component" do + expect(doctor.send(:bundler_config_directory, "webpack.config.js")).to be_nil end end end @@ -1672,6 +2130,82 @@ class << self end end + describe "react_on_rails_runtime_configuration" do + let(:doctor) { described_class.new(verbose: false, fix: false) } + let(:checker) { doctor.instance_variable_get(:@checker) } + + it "memoizes nil when rails environment cannot be loaded" do + expect(doctor).to receive(:ensure_rails_environment_loaded).once.and_return(false) + + expect(doctor.send(:react_on_rails_runtime_configuration)).to be_nil + expect(doctor.send(:react_on_rails_runtime_configuration)).to be_nil + end + + it "rescues and memoizes nil when ReactOnRails.configuration raises" do + allow(doctor).to receive(:ensure_rails_environment_loaded).and_return(true) + allow(ReactOnRails).to receive(:configuration).and_raise(StandardError, "bad config") + + expect(doctor.send(:react_on_rails_runtime_configuration)).to be_nil + expect(doctor.send(:react_on_rails_runtime_configuration)).to be_nil + + warning_messages = checker.messages.select { |m| m[:type] == :warning }.map { |m| m[:content] } + warning_count = + warning_messages.count { |msg| msg.include?("Could not query React on Rails runtime configuration") } + expect(warning_count).to eq(1) + end + end + + describe "resolved_pro_server_renderer" do + let(:doctor) { described_class.new(verbose: false, fix: false) } + let(:checker) { doctor.instance_variable_get(:@checker) } + + it "memoizes nil when Pro is not active" do + expect(ReactOnRails::Utils).to receive(:react_on_rails_pro?).once.and_return(false) + + expect(doctor.send(:resolved_pro_server_renderer)).to be_nil + expect(doctor.send(:resolved_pro_server_renderer)).to be_nil + end + + it "warns once when Pro is active but runtime and initializer renderer sources are unavailable" do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + allow(doctor).to receive_messages( + ensure_rails_environment_loaded: true, + pro_initializer_has_node_renderer?: false + ) + hide_const("ReactOnRailsPro") if defined?(ReactOnRailsPro) + + expect(doctor.send(:resolved_pro_server_renderer)).to be_nil + expect(doctor.send(:resolved_pro_server_renderer)).to be_nil + + warning_messages = checker.messages.select { |m| m[:type] == :warning }.map { |m| m[:content] } + expect(warning_messages.count { |msg| msg.include?("Could not determine Pro server renderer") }).to eq(1) + end + + it "adds an info message when Rails env is unavailable and initializer fallback is absent" do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + allow(doctor).to receive_messages( + ensure_rails_environment_loaded: false, + pro_initializer_has_node_renderer?: false + ) + + expect(doctor.send(:resolved_pro_server_renderer)).to be_nil + + info_messages = checker.messages.select { |m| m[:type] == :info }.map { |m| m[:content] } + expect(info_messages.any? { |msg| msg.include?("Rails environment unavailable and no initializer match found") }) + .to be true + end + + it "rescues LoadError when Pro runtime renderer cannot be queried" do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_raise(LoadError, "missing pro gem") + + expect(doctor.send(:resolved_pro_server_renderer)).to be_nil + + warning_messages = checker.messages.select { |m| m[:type] == :warning }.map { |m| m[:content] } + expect(warning_messages.any? { |msg| msg.include?("Could not read Pro runtime renderer configuration") }) + .to be true + end + end + describe "check_pro_initializer_existence" do let(:doctor) { described_class.new(verbose: false, fix: false) } let(:checker) { doctor.instance_variable_get(:@checker) } diff --git a/react_on_rails/spec/lib/react_on_rails/system_checker_spec.rb b/react_on_rails/spec/lib/react_on_rails/system_checker_spec.rb index 117fc2fa7f..1750127e9a 100644 --- a/react_on_rails/spec/lib/react_on_rails/system_checker_spec.rb +++ b/react_on_rails/spec/lib/react_on_rails/system_checker_spec.rb @@ -180,6 +180,17 @@ end end + describe "#check_webpack_configuration" do + it "mentions custom assets_bundler_config_path when no bundler config is found" do + allow(checker).to receive(:detect_bundler_config_path).and_return(nil) + + checker.check_webpack_configuration + + error_message = checker.messages.find { |msg| msg[:type] == :error }[:content] + expect(error_message).to include("assets_bundler_config_path") + end + end + describe "#check_react_on_rails_gem" do context "when gem is loaded" do before do @@ -677,34 +688,51 @@ describe "#shakapacker_configured?" do before do - allow(File).to receive(:exist?).and_call_original + allow(File).to receive_messages(exist?: false, file?: false) allow(File).to receive(:exist?).with("bin/shakapacker").and_return(true) allow(File).to receive(:exist?).with("bin/shakapacker-dev-server").and_return(true) allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(true) - # Default all config files to missing - allow(File).to receive(:exist?).with("config/webpack/webpack.config.js").and_return(false) - allow(File).to receive(:exist?).with("config/webpack/webpack.config.ts").and_return(false) - allow(File).to receive(:exist?).with("config/rspack/rspack.config.js").and_return(false) - allow(File).to receive(:exist?).with("config/rspack/rspack.config.ts").and_return(false) end it "returns true when webpack JS config exists" do - allow(File).to receive(:exist?).with("config/webpack/webpack.config.js").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.js").and_return(true) expect(checker.send(:shakapacker_configured?)).to be true end it "returns true when webpack TS config exists" do - allow(File).to receive(:exist?).with("config/webpack/webpack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.ts").and_return(true) expect(checker.send(:shakapacker_configured?)).to be true end it "returns true when rspack JS config exists" do - allow(File).to receive(:exist?).with("config/rspack/rspack.config.js").and_return(true) + allow(File).to receive(:file?).with("config/rspack/rspack.config.js").and_return(true) expect(checker.send(:shakapacker_configured?)).to be true end it "returns true when rspack TS config exists" do - allow(File).to receive(:exist?).with("config/rspack/rspack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/rspack/rspack.config.ts").and_return(true) + expect(checker.send(:shakapacker_configured?)).to be true + end + + it "returns true when shakapacker assets_bundler_config_path points to a custom config" do + allow(File).to receive(:file?).with("config/custom/custom-bundler.config.js").and_return(true) + allow(checker).to receive(:shakapacker_assets_bundler_config_path) + .and_return("config/custom/custom-bundler.config.js") + allow(checker).to receive(:bundler_config_directory) + .with("config/custom/custom-bundler.config.js") + .and_return("config/custom") + + expect(checker.send(:shakapacker_configured?)).to be true + end + + it "falls back to discovered defaults when explicit shakapacker assets_bundler_config_path is missing" do + allow(File).to receive(:file?).with("config/custom/missing.config.js").and_return(false) + allow(File).to receive(:file?).with("config/custom/webpack.config.js").and_return(true) + allow(checker).to receive(:shakapacker_assets_bundler_config_path).and_return("config/custom/missing.config.js") + allow(checker).to receive(:bundler_config_directory) + .with("config/custom/missing.config.js") + .and_return("config/custom") + expect(checker.send(:shakapacker_configured?)).to be true end @@ -714,25 +742,32 @@ it "returns false when binaries are missing" do allow(File).to receive(:exist?).with("bin/shakapacker").and_return(false) - allow(File).to receive(:exist?).with("config/webpack/webpack.config.js").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.js").and_return(true) expect(checker.send(:shakapacker_configured?)).to be false end end describe "#detect_bundler_config_path" do before do - allow(File).to receive(:exist?).and_return(false) + allow(File).to receive_messages(exist?: false, file?: false) end it "returns the existing webpack TypeScript config when only webpack exists" do - allow(File).to receive(:exist?).with("config/webpack/webpack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.ts").and_return(true) expect(checker.send(:detect_bundler_config_path)).to eq("config/webpack/webpack.config.ts") end + it "prefers webpack JavaScript config when both webpack defaults exist" do + allow(File).to receive(:file?).with("config/webpack/webpack.config.js").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.ts").and_return(true) + + expect(checker.send(:detect_bundler_config_path)).to eq("config/webpack/webpack.config.js") + end + it "prefers the bundler configured in shakapacker.yml when both bundlers exist" do - allow(File).to receive(:exist?).with("config/rspack/rspack.config.ts").and_return(true) - allow(File).to receive(:exist?).with("config/webpack/webpack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/rspack/rspack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.ts").and_return(true) allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(true) allow(File).to receive(:read).with("config/shakapacker.yml").and_return("default:\n assets_bundler: rspack\n") @@ -742,14 +777,78 @@ end).to be true end + it "prefers configured assets_bundler when both custom-directory candidates exist" do + allow(File).to receive(:file?).with("config/custom/missing.config.js").and_return(false) + allow(File).to receive(:file?).with("config/custom/webpack.config.js").and_return(true) + allow(File).to receive(:file?).with("config/custom/rspack.config.js").and_return(true) + allow(checker).to receive(:shakapacker_assets_bundler_config_path).and_return("config/custom/missing.config.js") + allow(checker).to receive(:bundler_config_directory) + .with("config/custom/missing.config.js") + .and_return("config/custom") + allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(true) + allow(File).to receive(:read).with("config/shakapacker.yml").and_return("default:\n assets_bundler: rspack\n") + + expect(checker.send(:detect_bundler_config_path)).to eq("config/custom/rspack.config.js") + expect(checker.messages.any? do |msg| + msg[:content].include?("Using rspack from config/shakapacker.yml") + end).to be true + end + it "warns and defaults to webpack when both bundlers exist and config is ambiguous" do - allow(File).to receive(:exist?).with("config/rspack/rspack.config.ts").and_return(true) - allow(File).to receive(:exist?).with("config/webpack/webpack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/rspack/rspack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.ts").and_return(true) allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(false) expect(checker.send(:detect_bundler_config_path)).to eq("config/webpack/webpack.config.ts") expect(checker.messages.any? { |msg| msg[:content].include?("defaulting to webpack") }).to be true end + + it "returns shakapacker's custom assets_bundler_config_path when present" do + allow(File).to receive(:file?).with("config/custom/custom-bundler.config.js").and_return(true) + allow(checker).to receive(:shakapacker_assets_bundler_config_path) + .and_return("config/custom/custom-bundler.config.js") + allow(checker).to receive(:bundler_config_directory) + .with("config/custom/custom-bundler.config.js") + .and_return("config/custom") + + expect(checker.send(:detect_bundler_config_path)).to eq("config/custom/custom-bundler.config.js") + end + + it "honors explicit shakapacker config path even when both default bundler files exist" do + allow(File).to receive(:file?).with("config/rspack/rspack.config.js").and_return(true) + allow(File).to receive(:file?).with("config/rspack/rspack.config.ts").and_return(false) + allow(File).to receive(:file?).with("config/webpack/webpack.config.ts").and_return(true) + allow(File).to receive(:file?).with("config/webpack/webpack.config.js").and_return(false) + allow(checker).to receive(:shakapacker_assets_bundler_config_path).and_return("config/rspack/rspack.config.js") + allow(checker).to receive(:bundler_config_directory) + .with("config/rspack/rspack.config.js") + .and_return("config/rspack") + + expect(checker.send(:detect_bundler_config_path)).to eq("config/rspack/rspack.config.js") + expect(checker.messages.any? { |msg| msg[:content].include?("defaulting to webpack") }).to be false + end + + it "ignores directory-valued custom assets_bundler_config_path entries" do + allow(File).to receive(:file?).with("config/custom").and_return(false) + allow(checker).to receive(:shakapacker_assets_bundler_config_path).and_return("config/custom") + allow(checker).to receive(:bundler_config_directory) + .with("config/custom") + .and_return("config/custom") + + expect(checker.send(:detect_bundler_config_path)).to be_nil + end + + it "falls back to resolved path if bundled classifier returns nil" do + allow(checker).to receive_messages( + resolved_webpack_config_path: "config/custom/custom-bundler.config.js", + resolve_default_bundler_config_path: nil + ) + allow(checker).to receive(:explicit_shakapacker_bundler_config_path?) + .with("config/custom/custom-bundler.config.js") + .and_return(false) + + expect(checker.send(:detect_bundler_config_path)).to eq("config/custom/custom-bundler.config.js") + end end describe "#configured_assets_bundler" do @@ -803,6 +902,33 @@ expect(info_messages).to include("Add to config/webpack/webpack.config.ts") end + + it "uses configured assets_bundler for explicit custom shakapacker config paths" do + allow(checker).to receive(:explicit_shakapacker_bundler_config_path?) + .with("config/custom/bundler.config.js") + .and_return(true) + allow(checker).to receive(:configured_assets_bundler).and_return("rspack") + + checker.send(:suggest_webpack_inspection, "config/custom/bundler.config.js") + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] }.join("\n") + + expect(info_messages).to include("debug rspack builds") + expect(info_messages).to include("rspack-stats.json") + end + + it "falls back to inferred bundler when custom shakapacker path has no assets_bundler setting" do + allow(checker).to receive(:explicit_shakapacker_bundler_config_path?) + .with("config/custom/bundler.config.js") + .and_return(true) + allow(checker).to receive(:configured_assets_bundler).and_return(nil) + + checker.send(:suggest_webpack_inspection, "config/custom/bundler.config.js") + info_messages = checker.messages.select { |msg| msg[:type] == :info }.map { |msg| msg[:content] }.join("\n") + + expect(info_messages).to include("debug webpack builds") + expect(info_messages).to include("webpack-stats.json") + expect(info_messages).to include("assets_bundler not set in config/shakapacker.yml") + end end describe "#standard_shakapacker_config?" do