From 3060f4a1b3d16ef718e7e967ee4a352593206bd2 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 18:05:45 -1000 Subject: [PATCH 01/16] Use runtime React on Rails config in doctor diagnostics --- react_on_rails/lib/react_on_rails/doctor.rb | 359 ++++++++++++------ .../spec/lib/react_on_rails/doctor_spec.rb | 116 ++++++ 2 files changed, 363 insertions(+), 112 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 50f59aed87..137843a60e 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -674,8 +674,8 @@ 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 = ReactOnRails::Utils.react_on_rails_pro? && pro_renderer == "NodeRenderer" if uses_node_renderer checker.add_info(" Pro uses NodeRenderer for server rendering") @@ -749,6 +749,7 @@ 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 unless File.exist?(config_path) checker.add_warning("⚠️ React on Rails configuration file not found: #{config_path}") @@ -761,40 +762,63 @@ def check_react_on_rails_initializer checker.add_info("📋 React on Rails Configuration:") checker.add_info("📍 Documentation: https://reactonrails.com/docs/guides/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 + server_bundle_value = runtime_config.server_bundle_js_file + if server_bundle_value.present? + checker.add_info(" server_bundle_js_file: #{server_bundle_value}") + 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: server-bundle.js (default)") + 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: #{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) @@ -806,33 +830,54 @@ def analyze_server_rendering_config(content) 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 + checker.add_info(" server_renderer_pool_size: #{runtime_config.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 + checker.add_info(" server_renderer_timeout: #{runtime_config.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}") + 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 +898,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: #{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 +914,162 @@ 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 + checker.add_info(" component_registry_timeout: #{runtime_config.component_registry_timeout}ms") + 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/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 + checker.add_info(" development_mode: #{runtime_config.development_mode}") + checker.add_info(" trace: #{runtime_config.trace}") + checker.add_info(" logging_on_server: #{runtime_config.logging_on_server}") + checker.add_info(" replay_console: #{runtime_config.replay_console}") + 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/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 = [] - 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}" + checker.add_info(" ℹ️ File-system based component registry enabled") + end + 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 + component_configs << "random_dom_id: #{runtime_config.random_dom_id}" unless runtime_config.random_dom_id + else + 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 - 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}") } 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/guides/rendering-extensions") - 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/guides/rendering-extensions") + 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 +1293,12 @@ 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 + return configured_value if configured_value.present? + end + # Try to read from React on Rails initializer initializer_path = "config/initializers/react_on_rails.rb" if File.exist?(initializer_path) @@ -1227,15 +1332,21 @@ def check_server_bundle_prerender_consistency checker.add_info("\n🔍 Server Rendering Consistency:") begin - content = File.read(config_path) + runtime_config = react_on_rails_runtime_configuration + if runtime_config + server_bundle_set = runtime_config.server_bundle_js_file.present? + 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 +2325,35 @@ 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 = ReactOnRails.configuration if ensure_rails_environment_loaded + rescue StandardError => e + checker.add_warning("⚠️ Could not query React on Rails runtime configuration: #{e.message}") + @react_on_rails_runtime_configuration = nil + end + + def resolved_pro_server_renderer + return nil unless ReactOnRails::Utils.react_on_rails_pro? + return @resolved_pro_server_renderer if defined?(@resolved_pro_server_renderer) + + @resolved_pro_server_renderer = + if ensure_rails_environment_loaded && defined?(ReactOnRailsPro) + ReactOnRailsPro.configuration.server_renderer + elsif pro_initializer_has_node_renderer? + "NodeRenderer" + end + rescue StandardError => e + checker.add_warning(" ⚠️ Could not read Pro runtime renderer configuration: #{e.message}") + @resolved_pro_server_renderer = nil + 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/spec/lib/react_on_rails/doctor_spec.rb b/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb index 47ccbca910..a24a291860 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,74 @@ 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: true, + 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("initializer-server-bundle.js")) + end + end + describe "server bundle path detection" do let(:doctor) { described_class.new } @@ -168,6 +236,7 @@ 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 +249,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 @@ -1103,6 +1173,34 @@ 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 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 +1296,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(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + allow(doctor).to receive(:resolved_pro_server_renderer).and_return("NodeRenderer") + end + + it "treats ExecJS as fallback" do + stub_const("ExecJS", execjs_module) + + 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) From cc8eed765d6dd894167dd49757ea7c6764908c8b Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 18:13:42 -1000 Subject: [PATCH 02/16] Address doctor runtime-config review findings --- react_on_rails/lib/react_on_rails/doctor.rb | 9 ++--- .../spec/lib/react_on_rails/doctor_spec.rb | 35 ++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 137843a60e..7c7d28e0ed 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -1015,7 +1015,7 @@ def analyze_component_loading_config(content, runtime_config = nil) 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 - component_configs << "random_dom_id: #{runtime_config.random_dom_id}" unless runtime_config.random_dom_id + 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 @@ -1327,12 +1327,12 @@ 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 - runtime_config = react_on_rails_runtime_configuration if runtime_config server_bundle_set = runtime_config.server_bundle_js_file.present? prerender_set = runtime_config.prerender @@ -2333,7 +2333,8 @@ def ensure_rails_environment_loaded def react_on_rails_runtime_configuration return @react_on_rails_runtime_configuration if defined?(@react_on_rails_runtime_configuration) - @react_on_rails_runtime_configuration = ReactOnRails.configuration if ensure_rails_environment_loaded + @react_on_rails_runtime_configuration = + ensure_rails_environment_loaded ? ReactOnRails.configuration : nil rescue StandardError => e checker.add_warning("⚠️ Could not query React on Rails runtime configuration: #{e.message}") @react_on_rails_runtime_configuration = nil 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 a24a291860..4c99549e47 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 @@ -111,7 +111,7 @@ i18n_output_format: nil, components_subdirectory: nil, same_bundle_for_client_and_server: false, - random_dom_id: true, + random_dom_id: nil, rendering_extension: nil, rendering_props_extension: nil, server_render_method: nil @@ -148,6 +148,7 @@ def write_project_file(path, content) 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 end @@ -1201,6 +1202,27 @@ def write_project_file(path, content) 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 views use stream_react_component (RSC/streaming apps)" do it "reports consistent configuration" do write_project_file("config/initializers/react_on_rails.rb", <<~RUBY) @@ -1788,6 +1810,17 @@ class << self end end + describe "react_on_rails_runtime_configuration" do + let(:doctor) { described_class.new(verbose: false, fix: false) } + + 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 + end + describe "check_pro_initializer_existence" do let(:doctor) { described_class.new(verbose: false, fix: false) } let(:checker) { doctor.instance_variable_get(:@checker) } From 26ef54a2e474828f3913de40f8bb2ad2193bc808 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:20:07 -1000 Subject: [PATCH 03/16] Fix doctor runtime fallback gaps and clear lint regressions --- .../src/create-app.ts | 8 ++- react_on_rails/lib/react_on_rails/doctor.rb | 16 ++++-- .../spec/lib/react_on_rails/doctor_spec.rb | 50 ++++++++++++++++++- 3 files changed, 63 insertions(+), 11 deletions(-) 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/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 7c7d28e0ed..f8b14674ea 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.empty? + checker.add_info("ℹ️ server_bundle_js_file is blank (SSR disabled), skipping server bundle file 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}") @@ -675,7 +680,7 @@ def check_server_rendering_engine begin pro_renderer = resolved_pro_server_renderer - uses_node_renderer = ReactOnRails::Utils.react_on_rails_pro? && pro_renderer == "NodeRenderer" + uses_node_renderer = pro_renderer == "NodeRenderer" if uses_node_renderer checker.add_info(" Pro uses NodeRenderer for server rendering") @@ -750,15 +755,18 @@ 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 + checker.add_warning("⚠️ React on Rails configuration file not found: #{config_path}") unless initializer_exists + 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/guides/configuration/") @@ -1296,7 +1304,7 @@ def server_bundle_filename runtime_config = react_on_rails_runtime_configuration if runtime_config configured_value = runtime_config.server_bundle_js_file - return configured_value if configured_value.present? + return configured_value unless configured_value.nil? end # Try to read from React on Rails initializer 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 4c99549e47..3ae06d9174 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 @@ -151,6 +151,21 @@ def write_project_file(path, content) 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).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("Using loaded runtime configuration values")) + expect(info_messages).to include(a_string_including("server_bundle_js_file: runtime-server-bundle.js")) + end end describe "server bundle path detection" do @@ -231,6 +246,37 @@ def write_project_file(path, content) 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"' @@ -1320,12 +1366,12 @@ def write_project_file(path, content) context "when Pro runtime config reports NodeRenderer without initializer text" do before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) allow(doctor).to receive(:resolved_pro_server_renderer).and_return("NodeRenderer") end - it "treats ExecJS as fallback" do + 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) From 8affe083d783bb4d7c26c593124b305c3a1fb2ad Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:20:54 -1000 Subject: [PATCH 04/16] Clarify doctor runtime-reporting intent in comments --- react_on_rails/lib/react_on_rails/doctor.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index f8b14674ea..a2b2b1700a 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -1023,6 +1023,7 @@ def analyze_component_loading_config(content, runtime_config = nil) 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*["']([^"']+)["']/) @@ -2345,6 +2346,7 @@ def react_on_rails_runtime_configuration ensure_rails_environment_loaded ? ReactOnRails.configuration : nil rescue StandardError => 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 From 00d959697cd6e8406ed80c93c5a82a9ca298dfe1 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:43:08 -1000 Subject: [PATCH 05/16] Refine doctor runtime config reporting and memoization --- .../src/create-app.ts | 5 +- .../create-react-on-rails-app/src/index.ts | 7 +- react_on_rails/lib/react_on_rails/doctor.rb | 29 ++++++-- .../spec/lib/react_on_rails/doctor_spec.rb | 69 ++++++++++++++++++- 4 files changed, 92 insertions(+), 18 deletions(-) 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 a19a176106..0489fc1bee 100644 --- a/packages/create-react-on-rails-app/src/create-app.ts +++ b/packages/create-react-on-rails-app/src/create-app.ts @@ -226,10 +226,7 @@ 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: '--rsc' | '--pro' | null = null; - if (proRequested) { - proModeLabel = options.rsc ? '--rsc' : '--pro'; - } + const proModeLabel = proRequested ? (options.rsc ? '--rsc' : '--pro') : null; const baseSteps = 3; // rails new + add react_on_rails + run generator const totalSteps = baseSteps + (proRequested ? 1 : 0); let currentStep = 1; diff --git a/packages/create-react-on-rails-app/src/index.ts b/packages/create-react-on-rails-app/src/index.ts index 884cbcff05..e0aadc1dda 100644 --- a/packages/create-react-on-rails-app/src/index.ts +++ b/packages/create-react-on-rails-app/src/index.ts @@ -80,12 +80,7 @@ function run(appName: string, rawOpts: Record): void { } console.log(''); - let modeLabel = ''; - if (options.rsc) { - modeLabel = ', mode: rsc'; - } else if (options.pro) { - modeLabel = ', mode: pro'; - } + const modeLabel = options.rsc ? ', mode: rsc' : options.pro ? ', mode: pro' : ''; logInfo( `Creating "${appName}" with template: ${options.template}, package manager: ${options.packageManager}${options.rspack ? ', bundler: rspack' : ''}${modeLabel}`, ); diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index a2b2b1700a..1a66b64651 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -198,8 +198,8 @@ def check_development end def check_javascript_bundles - if server_bundle_filename.to_s.empty? - checker.add_info("ℹ️ server_bundle_js_file is blank (SSR disabled), skipping server bundle file check") + 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 @@ -763,7 +763,9 @@ def check_react_on_rails_initializer return end - checker.add_warning("⚠️ React on Rails configuration file not found: #{config_path}") unless initializer_exists + if !initializer_exists && runtime_config + checker.add_info("ℹ️ No config/initializers/react_on_rails.rb found (using runtime configuration)") + end begin content = initializer_exists ? File.read(config_path) : "" @@ -796,6 +798,8 @@ def analyze_server_rendering_config(content, runtime_config = nil) server_bundle_value = runtime_config.server_bundle_js_file if server_bundle_value.present? checker.add_info(" server_bundle_js_file: #{server_bundle_value}") + elsif server_bundle_value.nil? + checker.add_info(" server_bundle_js_file: #{server_bundle_filename} (initializer/default)") else checker.add_info(" server_bundle_js_file: \"\" (disabled)") end @@ -849,14 +853,18 @@ def analyze_server_rendering_config(content, runtime_config = nil) # Server renderer pool settings if runtime_config - checker.add_info(" server_renderer_pool_size: #{runtime_config.server_renderer_pool_size}") + # 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 != 1 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 if runtime_config - checker.add_info(" server_renderer_timeout: #{runtime_config.server_renderer_timeout} seconds") + # 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 != 20 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 @@ -1020,6 +1028,7 @@ def analyze_component_loading_config(content, runtime_config = nil) component_configs << "components_subdirectory: #{runtime_config.components_subdirectory}" checker.add_info(" ℹ️ File-system based component registry enabled") 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 @@ -1343,7 +1352,13 @@ def check_server_bundle_prerender_consistency begin if runtime_config - server_bundle_set = runtime_config.server_bundle_js_file.present? + 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) @@ -2351,8 +2366,8 @@ def react_on_rails_runtime_configuration end def resolved_pro_server_renderer - return nil unless ReactOnRails::Utils.react_on_rails_pro? return @resolved_pro_server_renderer if defined?(@resolved_pro_server_renderer) + return (@resolved_pro_server_renderer = nil) unless ReactOnRails::Utils.react_on_rails_pro? @resolved_pro_server_renderer = if ensure_rails_environment_loaded && defined?(ReactOnRailsPro) 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 3ae06d9174..b63f9f91ec 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 @@ -160,12 +160,27 @@ def write_project_file(path, content) 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).to include( + 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 end describe "server bundle path detection" do @@ -308,6 +323,23 @@ def write_project_file(path, content) 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) } @@ -1269,6 +1301,30 @@ def write_project_file(path, content) 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) @@ -1867,6 +1923,17 @@ class << self end end + describe "resolved_pro_server_renderer" do + let(:doctor) { described_class.new(verbose: false, fix: false) } + + 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 + end + describe "check_pro_initializer_existence" do let(:doctor) { described_class.new(verbose: false, fix: false) } let(:checker) { doctor.instance_variable_get(:@checker) } From cb31702eedaf3bffee430192605b5d01d7aa5321 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 21:55:09 -1000 Subject: [PATCH 06/16] Reduce runtime doctor noise and clarify renderer diagnostics --- react_on_rails/lib/react_on_rails/doctor.rb | 10 +++++++--- react_on_rails/spec/lib/react_on_rails/doctor_spec.rb | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 1a66b64651..de3e7e91e0 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -796,10 +796,11 @@ def analyze_server_rendering_config(content, runtime_config = nil) if runtime_config server_bundle_value = runtime_config.server_bundle_js_file + fallback_server_bundle = server_bundle_filename if server_bundle_value.present? checker.add_info(" server_bundle_js_file: #{server_bundle_value}") elsif server_bundle_value.nil? - checker.add_info(" server_bundle_js_file: #{server_bundle_filename} (initializer/default)") + checker.add_info(" server_bundle_js_file: #{fallback_server_bundle} (initializer/default)") else checker.add_info(" server_bundle_js_file: \"\" (disabled)") end @@ -931,7 +932,9 @@ def analyze_performance_config(content, runtime_config = nil) # Component registry timeout if runtime_config - checker.add_info(" component_registry_timeout: #{runtime_config.component_registry_timeout}ms") + # 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 != 5000 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 @@ -2371,12 +2374,13 @@ def resolved_pro_server_renderer @resolved_pro_server_renderer = if ensure_rails_environment_loaded && defined?(ReactOnRailsPro) + # server_renderer is stored as a plain string in Pro config (for example, "NodeRenderer"). ReactOnRailsPro.configuration.server_renderer elsif pro_initializer_has_node_renderer? "NodeRenderer" end rescue StandardError => e - checker.add_warning(" ⚠️ Could not read Pro runtime renderer configuration: #{e.message}") + checker.add_warning("⚠️ Could not read Pro runtime renderer configuration: #{e.message}") @resolved_pro_server_renderer = nil end 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 b63f9f91ec..7ca2e02fcb 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 @@ -181,6 +181,16 @@ def write_project_file(path, content) a_string_including("server_bundle_js_file: server-bundle.js (initializer/default)") ) 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 end describe "server bundle path detection" do From ed698789ff9615781b696679f06abd6b8b7fb5fc Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:04:36 -1000 Subject: [PATCH 07/16] Refine doctor runtime reporting defaults --- react_on_rails/lib/react_on_rails/doctor.rb | 29 +++++++++++++------ .../spec/lib/react_on_rails/doctor_spec.rb | 20 +++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index de3e7e91e0..ec4b13a1a4 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -53,6 +53,9 @@ class Doctor MINITEST_HELPER_FILE = "test/test_helper.rb" DEFAULT_BUILD_TEST_COMMAND = 'config.build_test_command = "RAILS_ENV=test bin/shakapacker"' DEFAULT_SHAKAPACKER_CONFIG_PATH = "config/shakapacker.yml" + DEFAULT_SERVER_RENDERER_POOL_SIZE = 1 + DEFAULT_SERVER_RENDERER_TIMEOUT_SECONDS = 20 + DEFAULT_COMPONENT_REGISTRY_TIMEOUT_MS = 5000 def initialize(verbose: false, fix: false) @verbose = verbose @@ -796,10 +799,10 @@ def analyze_server_rendering_config(content, runtime_config = nil) if runtime_config server_bundle_value = runtime_config.server_bundle_js_file - fallback_server_bundle = server_bundle_filename 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)") @@ -827,7 +830,7 @@ def analyze_server_rendering_config(content, runtime_config = nil) # Enforce private server bundles if runtime_config - checker.add_info(" enforce_private_server_bundles: #{runtime_config.enforce_private_server_bundles}") + 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 @@ -837,9 +840,16 @@ def analyze_server_rendering_config(content, runtime_config = nil) 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)") + if runtime_config.respond_to?(:rsc_bundle_js_file) + rsc_bundle_value = runtime_config.rsc_bundle_js_file + if rsc_bundle_value.present? + checker.add_info(" rsc_bundle_js_file: #{rsc_bundle_value} (React Server Components - Pro)") + end + else + 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)") + end end # Prerender setting @@ -856,7 +866,7 @@ def analyze_server_rendering_config(content, runtime_config = nil) 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 != 1 + if runtime_config.server_renderer_pool_size != 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 @@ -865,7 +875,7 @@ def analyze_server_rendering_config(content, runtime_config = nil) 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 != 20 + if runtime_config.server_renderer_timeout != 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 @@ -873,7 +883,8 @@ def analyze_server_rendering_config(content, runtime_config = nil) # Error handling if runtime_config - checker.add_info(" raise_on_prerender_error: #{runtime_config.raise_on_prerender_error}") + 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 @@ -934,7 +945,7 @@ def analyze_performance_config(content, runtime_config = nil) 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 != 5000 + if runtime_config.component_registry_timeout != DEFAULT_COMPONENT_REGISTRY_TIMEOUT_MS 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 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 7ca2e02fcb..3a72b22580 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 @@ -191,6 +191,26 @@ def write_project_file(path, content) 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 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 From 2b9b7c5db6757175bf8498ccfbe6583c1628c448 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 22:12:08 -1000 Subject: [PATCH 08/16] Reduce doctor runtime noise and centralize defaults --- .../lib/react_on_rails/configuration.rb | 6 ++-- react_on_rails/lib/react_on_rails/doctor.rb | 23 +++++++-------- .../spec/lib/react_on_rails/doctor_spec.rb | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/configuration.rb b/react_on_rails/lib/react_on_rails/configuration.rb index 2531c16f0e..6187649d2b 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 ec4b13a1a4..0cb69266e7 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -53,9 +53,6 @@ class Doctor MINITEST_HELPER_FILE = "test/test_helper.rb" DEFAULT_BUILD_TEST_COMMAND = 'config.build_test_command = "RAILS_ENV=test bin/shakapacker"' DEFAULT_SHAKAPACKER_CONFIG_PATH = "config/shakapacker.yml" - DEFAULT_SERVER_RENDERER_POOL_SIZE = 1 - DEFAULT_SERVER_RENDERER_TIMEOUT_SECONDS = 20 - DEFAULT_COMPONENT_REGISTRY_TIMEOUT_MS = 5000 def initialize(verbose: false, fix: false) @verbose = verbose @@ -866,7 +863,7 @@ def analyze_server_rendering_config(content, runtime_config = nil) 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 != DEFAULT_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 @@ -875,7 +872,7 @@ def analyze_server_rendering_config(content, runtime_config = nil) 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 != DEFAULT_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 @@ -927,7 +924,7 @@ def analyze_performance_config(content, runtime_config = nil) # Auto load bundle if runtime_config - checker.add_info(" auto_load_bundle: #{runtime_config.auto_load_bundle}") + 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 @@ -945,7 +942,7 @@ def analyze_performance_config(content, runtime_config = nil) 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 != DEFAULT_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 @@ -958,10 +955,13 @@ def analyze_development_config(content, runtime_config = nil) checker.add_info("\n🔧 Development & Debugging:") if runtime_config - checker.add_info(" development_mode: #{runtime_config.development_mode}") - checker.add_info(" trace: #{runtime_config.trace}") - checker.add_info(" logging_on_server: #{runtime_config.logging_on_server}") - checker.add_info(" replay_console: #{runtime_config.replay_console}") + # Defaults are environment-sensitive for development_mode/trace and static for logging/replay. + 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? + checker.add_info(" logging_on_server: false") unless runtime_config.logging_on_server + checker.add_info(" replay_console: false") unless runtime_config.replay_console if runtime_config.build_test_command.present? checker.add_info(" build_test_command: #{runtime_config.build_test_command}") end @@ -1328,6 +1328,7 @@ 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 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 3a72b22580..0a8343cd17 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 @@ -192,6 +192,34 @@ def write_project_file(path, 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 "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) From 942423b6bea8aafeeed8ee8395c84f202a004fb7 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 23 Mar 2026 23:03:31 -1000 Subject: [PATCH 09/16] Replace nested ternaries in create-app CLI flow --- packages/create-react-on-rails-app/src/create-app.ts | 5 ++++- packages/create-react-on-rails-app/src/index.ts | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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 0489fc1bee..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,7 +226,10 @@ 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; - const proModeLabel = proRequested ? (options.rsc ? '--rsc' : '--pro') : null; + 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); let currentStep = 1; diff --git a/packages/create-react-on-rails-app/src/index.ts b/packages/create-react-on-rails-app/src/index.ts index e0aadc1dda..884cbcff05 100644 --- a/packages/create-react-on-rails-app/src/index.ts +++ b/packages/create-react-on-rails-app/src/index.ts @@ -80,7 +80,12 @@ function run(appName: string, rawOpts: Record): void { } console.log(''); - const modeLabel = options.rsc ? ', mode: rsc' : options.pro ? ', mode: pro' : ''; + let modeLabel = ''; + if (options.rsc) { + modeLabel = ', mode: rsc'; + } else if (options.pro) { + modeLabel = ', mode: pro'; + } logInfo( `Creating "${appName}" with template: ${options.template}, package manager: ${options.packageManager}${options.rspack ? ', bundler: rspack' : ''}${modeLabel}`, ); From 42327a453fdc7cbda2adf55c9a0490158d745c36 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 18:14:00 -1000 Subject: [PATCH 10/16] Harden doctor runtime config diagnostics --- react_on_rails/lib/react_on_rails/doctor.rb | 26 +++++++++++++++-- .../spec/lib/react_on_rails/doctor_spec.rb | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 0cb69266e7..ce9f5c15bf 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -795,7 +795,13 @@ def analyze_server_rendering_config(content, runtime_config = nil) checker.add_info("\n🖥️ Server Rendering:") if runtime_config - server_bundle_value = runtime_config.server_bundle_js_file + 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? @@ -837,7 +843,14 @@ def analyze_server_rendering_config(content, runtime_config = nil) check_shakapacker_private_output_path(rails_bundle_path) # RSC bundle file (Pro feature) - if runtime_config.respond_to?(:rsc_bundle_js_file) + runtime_config_supports_rsc_bundle = + case runtime_config + when nil + false + else + runtime_config.respond_to?(:rsc_bundle_js_file) + end + if runtime_config_supports_rsc_bundle rsc_bundle_value = runtime_config.rsc_bundle_js_file if rsc_bundle_value.present? checker.add_info(" rsc_bundle_js_file: #{rsc_bundle_value} (React Server Components - Pro)") @@ -2384,12 +2397,19 @@ 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 ensure_rails_environment_loaded && defined?(ReactOnRailsPro) + if rails_environment_loaded && defined?(ReactOnRailsPro) # server_renderer is stored as a plain string in Pro config (for example, "NodeRenderer"). ReactOnRailsPro.configuration.server_renderer 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 end rescue StandardError => e checker.add_warning("⚠️ Could not read Pro runtime renderer configuration: #{e.message}") 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 0a8343cd17..a118a27ec0 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 @@ -182,6 +182,18 @@ def write_project_file(path, content) ) 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 "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) @@ -1983,6 +1995,7 @@ class << self 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) @@ -1990,6 +2003,21 @@ class << self 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 end describe "check_pro_initializer_existence" do From 8e4ae15d27fa05e5fead5172365b523c0f12c528 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 18:30:49 -1000 Subject: [PATCH 11/16] Clarify doctor fallback diagnostics --- react_on_rails/lib/react_on_rails/doctor.rb | 17 ++++++++++--- .../spec/lib/react_on_rails/doctor_spec.rb | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index ce9f5c15bf..e5cc65d310 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -963,7 +963,7 @@ def analyze_performance_config(content, runtime_config = nil) end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def analyze_development_config(content, runtime_config = nil) checker.add_info("\n🔧 Development & Debugging:") @@ -973,8 +973,12 @@ def analyze_development_config(content, runtime_config = nil) if runtime_config.development_mode != Rails.env.development? checker.add_info(" trace: #{runtime_config.trace}") \ if runtime_config.trace != Rails.env.development? - checker.add_info(" logging_on_server: false") unless runtime_config.logging_on_server - checker.add_info(" replay_console: false") unless runtime_config.replay_console + 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 @@ -1016,7 +1020,7 @@ def analyze_development_config(content, runtime_config = nil) checker.add_info(" build_production_command: #{build_prod_match[1]}") if build_prod_match end end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def analyze_i18n_config(content, runtime_config = nil) @@ -2410,6 +2414,11 @@ def resolved_pro_server_renderer "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 => e checker.add_warning("⚠️ Could not read Pro runtime renderer configuration: #{e.message}") 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 a118a27ec0..3413e3600d 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 @@ -232,6 +232,17 @@ def write_project_file(path, content) 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) @@ -2018,6 +2029,20 @@ class << self 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 end describe "check_pro_initializer_existence" do From 04019c877aa3c1adc491a948ee2f08ef267b6bb0 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 24 Mar 2026 18:38:41 -1000 Subject: [PATCH 12/16] Polish doctor messaging and resilience --- react_on_rails/lib/react_on_rails/doctor.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index e5cc65d310..5ec93d5ad6 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -968,11 +968,15 @@ def analyze_development_config(content, runtime_config = nil) checker.add_info("\n🔧 Development & Debugging:") if runtime_config - # Defaults are environment-sensitive for development_mode/trace and static for logging/replay. + # 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 @@ -1053,11 +1057,12 @@ def analyze_i18n_config(content, runtime_config = nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def analyze_component_loading_config(content, runtime_config = nil) component_configs = [] + filesystem_registry_enabled = false if runtime_config if runtime_config.components_subdirectory.present? component_configs << "components_subdirectory: #{runtime_config.components_subdirectory}" - checker.add_info(" ℹ️ File-system based component registry enabled") + filesystem_registry_enabled = true end # Default is false; only report explicit non-default override. if runtime_config.same_bundle_for_client_and_server @@ -1069,7 +1074,7 @@ def analyze_component_loading_config(content, runtime_config = nil) 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") + filesystem_registry_enabled = true end same_bundle_match = content.match(/config\.same_bundle_for_client_and_server\s*=\s*([^\s\n,]+)/) @@ -1083,6 +1088,7 @@ def analyze_component_loading_config(content, runtime_config = nil) 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 @@ -2391,7 +2397,7 @@ def react_on_rails_runtime_configuration @react_on_rails_runtime_configuration = ensure_rails_environment_loaded ? ReactOnRails.configuration : nil - rescue StandardError => e + 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 @@ -2405,7 +2411,7 @@ def resolved_pro_server_renderer @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 + ReactOnRailsPro.configuration.server_renderer.to_s elsif pro_initializer_has_node_renderer? "NodeRenderer" elsif rails_environment_loaded From 61aad434f653e96a322b0dd2251cb474548a96c9 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 27 Mar 2026 22:26:44 -1000 Subject: [PATCH 13/16] Rescue LoadError in doctor runtime query --- react_on_rails/lib/react_on_rails/doctor.rb | 2 +- react_on_rails/spec/lib/react_on_rails/doctor_spec.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 34d53f2856..31c8d163d7 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -2426,7 +2426,7 @@ def resolved_pro_server_renderer ) nil end - rescue StandardError => e + rescue StandardError, LoadError => e checker.add_warning("⚠️ Could not read Pro runtime renderer configuration: #{e.message}") @resolved_pro_server_renderer = nil end 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 3413e3600d..99f2a2ef13 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 @@ -2043,6 +2043,16 @@ class << self 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 From 4aab3c2efd721f87d4c9ba07b5a343d7cc242565 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Fri, 27 Mar 2026 23:48:53 -1000 Subject: [PATCH 14/16] Doctor: tighten webpack config diagnostics for custom shakapacker paths (#2824) ## Summary - prioritize exact `Shakapacker.config.assets_bundler_config_path` as the first webpack config candidate in `ConfigPathResolver` - keep directory-derived and default webpack/rspack fallback candidates after the exact path - make `SystemChecker#bundler_config_file_exists?` use resolver-based path detection so custom config paths are recognized - update doctor missing-config messaging to reference generic bundler config discovery instead of only `config/webpack/webpack.config.js` - add coverage for exact-path precedence and custom-path detection in doctor/system checker specs ## Testing - `bundle exec rspec react_on_rails/spec/lib/react_on_rails/doctor_spec.rb react_on_rails/spec/lib/react_on_rails/system_checker_spec.rb` - `bundle exec rubocop react_on_rails/lib/react_on_rails/config_path_resolver.rb react_on_rails/lib/react_on_rails/doctor.rb react_on_rails/lib/react_on_rails/system_checker.rb react_on_rails/spec/lib/react_on_rails/doctor_spec.rb react_on_rails/spec/lib/react_on_rails/system_checker_spec.rb` Fixes #2633 --- > [!NOTE] > **Medium Risk** > Changes bundler config discovery/selection logic (including candidate ordering and shakapacker-derived paths), which can alter which webpack/rspack config file is detected and what guidance/warnings are emitted during doctor/install checks. > > **Overview** > **Improves bundler config path detection for Doctor/SystemChecker**, especially when apps use a custom `Shakapacker.config.assets_bundler_config_path`. > > Config resolution now **prioritizes the exact Shakapacker-configured path**, probes standard `webpack.config.*`/`rspack.config.*` siblings in the same directory (including `.cjs`/`.mjs` there), and falls back to deterministic default locations with **`.js` preferred over `.ts`**. Doctor and SystemChecker messaging is updated to refer to generic *bundler configuration* discovery (not just `config/webpack/webpack.config.js`), and specs add coverage for these precedence/ambiguity cases. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c653830e2cfad4f4e42e1a634f1d505c4535d234. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). ### Note - Behavior change: config candidate ordering now prefers before for both webpack and rspack candidates (including default fallbacks) to match current generator output and keep selection deterministic when both files exist. ### Note - Behavior change: config candidate ordering now prefers `.js` before `.ts` for both webpack and rspack candidates (including default fallbacks) to match current generator output and keep selection deterministic when both files exist. --- .../react_on_rails/config_path_resolver.rb | 80 +++++++-- react_on_rails/lib/react_on_rails/doctor.rb | 6 +- .../lib/react_on_rails/system_checker.rb | 138 ++++++++++----- .../spec/lib/react_on_rails/doctor_spec.rb | 110 ++++++++++-- .../lib/react_on_rails/system_checker_spec.rb | 160 ++++++++++++++++-- 5 files changed, 407 insertions(+), 87 deletions(-) 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..56c1e01570 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,15 +24,26 @@ 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 + shakapacker_basename = File.basename(shakapacker_config_path.to_s) + shakapacker_config_ext = File.extname(shakapacker_config_path.to_s).delete_prefix(".") candidates.concat(%w[js ts cjs mjs].flat_map do |ext| + # Skip only exact standard-name duplicates. Non-standard configured + # paths (for example `custom.config.cjs`) still probe standard-name + # fallbacks in the same directory; any accidental duplicates are + # de-duplicated by `candidates.uniq` below. + config_basenames = ["webpack.config.#{ext}", "rspack.config.#{ext}"] + next [] if ext == shakapacker_config_ext && config_basenames.include?(shakapacker_basename) + [ File.join(shakapacker_config_dir, "webpack.config.#{ext}"), File.join(shakapacker_config_dir, "rspack.config.#{ext}") @@ -28,23 +51,56 @@ def webpack_config_candidates 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/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 31c8d163d7..763eb114c3 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -633,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 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 99f2a2ef13..f147235139 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 @@ -1823,36 +1823,60 @@ 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 "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 @@ -1862,7 +1886,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 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 From 7df8bb253a780a7b4fc172a7a23ceb12ef5aec16 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 28 Mar 2026 10:01:46 -1000 Subject: [PATCH 15/16] Doctor: tighten NodeRenderer fallback and config diagnostics --- react_on_rails/lib/react_on_rails/doctor.rb | 44 ++++++++------ .../spec/lib/react_on_rails/doctor_spec.rb | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/react_on_rails/lib/react_on_rails/doctor.rb b/react_on_rails/lib/react_on_rails/doctor.rb index 763eb114c3..2f69e05920 100644 --- a/react_on_rails/lib/react_on_rails/doctor.rb +++ b/react_on_rails/lib/react_on_rails/doctor.rb @@ -686,9 +686,11 @@ def check_server_rendering_engine 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 @@ -816,7 +818,7 @@ def analyze_server_rendering_config(content, runtime_config = nil) if server_bundle_match checker.add_info(" server_bundle_js_file: #{server_bundle_match[1]}") else - checker.add_info(" server_bundle_js_file: server-bundle.js (default)") + checker.add_info(' server_bundle_js_file: "" (default, SSR disabled)') end end @@ -842,24 +844,16 @@ def analyze_server_rendering_config(content, runtime_config = nil) # Check Shakapacker integration and provide recommendations check_shakapacker_private_output_path(rails_bundle_path) - # RSC bundle file (Pro feature) - runtime_config_supports_rsc_bundle = - case runtime_config - when nil - false + # 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 - runtime_config.respond_to?(:rsc_bundle_js_file) - end - if runtime_config_supports_rsc_bundle - rsc_bundle_value = runtime_config.rsc_bundle_js_file - if rsc_bundle_value.present? - checker.add_info(" rsc_bundle_js_file: #{rsc_bundle_value} (React Server Components - Pro)") - end - else - 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_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 @@ -2431,6 +2425,20 @@ def resolved_pro_server_renderer @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/spec/lib/react_on_rails/doctor_spec.rb b/react_on_rails/spec/lib/react_on_rails/doctor_spec.rb index f147235139..2e18ed31e8 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 @@ -194,6 +194,22 @@ def write_project_file(path, content) ) 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) @@ -1564,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) @@ -2077,6 +2120,7 @@ class << self 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) @@ -2084,6 +2128,19 @@ class << self 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 From 04a8ebc975c41842bef3aca800934f8fe16234d0 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 28 Mar 2026 12:20:12 -1000 Subject: [PATCH 16/16] Fix bundler config candidate dedup in resolver --- .../lib/react_on_rails/config_path_resolver.rb | 14 ++++---------- .../spec/lib/react_on_rails/doctor_spec.rb | 12 ++++++++++++ 2 files changed, 16 insertions(+), 10 deletions(-) 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 56c1e01570..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 @@ -34,20 +34,14 @@ def webpack_config_candidates shakapacker_config_dir = bundler_config_directory(shakapacker_config_path) if shakapacker_config_dir - shakapacker_basename = File.basename(shakapacker_config_path.to_s) - shakapacker_config_ext = File.extname(shakapacker_config_path.to_s).delete_prefix(".") candidates.concat(%w[js ts cjs mjs].flat_map do |ext| - # Skip only exact standard-name duplicates. Non-standard configured - # paths (for example `custom.config.cjs`) still probe standard-name - # fallbacks in the same directory; any accidental duplicates are - # de-duplicated by `candidates.uniq` below. - config_basenames = ["webpack.config.#{ext}", "rspack.config.#{ext}"] - next [] if ext == shakapacker_config_ext && config_basenames.include?(shakapacker_basename) - + # 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 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 2e18ed31e8..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 @@ -1891,6 +1891,18 @@ def write_project_file(path, content) 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(:file?).and_return(false) allow(File).to receive(:file?).with("config/rspack/rspack.config.ts").and_return(true)