diff --git a/docs/api-reference/configuration.md b/docs/api-reference/configuration.md index 19e6fd9549..2675624460 100644 --- a/docs/api-reference/configuration.md +++ b/docs/api-reference/configuration.md @@ -94,6 +94,252 @@ ReactOnRails.configure do |config| # This controls what command is run to build assets during tests ################################################################################ config.build_test_command = "RAILS_ENV=test bin/shakapacker" + + # + # React Server Components and Streaming SSR are React on Rails Pro features. + # For detailed configuration of RSC and streaming features, see: + # https://github.com/shakacode/react_on_rails/blob/master/react_on_rails_pro/docs/configuration.md + # + # Key Pro configurations (configured in ReactOnRailsPro.configure block): + # - rsc_bundle_js_file: Path to RSC bundle + # - react_client_manifest_file: Client component manifest for RSC + # - react_server_client_manifest_file: Server manifest for RSC + # - enable_rsc_support: Enable React Server Components + # + # See Pro documentation for complete setup instructions. + + ################################################################################ + # SERVER BUNDLE SECURITY AND ORGANIZATION + ################################################################################ + + # ⚠️ RECOMMENDED: Use Shakapacker 9.0+ for Automatic Configuration + # + # For Shakapacker 9.0+, add to config/shakapacker.yml: + # private_output_path: ssr-generated + # + # React on Rails will automatically detect and use this value, eliminating the need + # to configure server_bundle_output_path here. This provides a single source of truth. + # + # For older Shakapacker versions or custom setups, manually configure: + # This configures the directory (relative to the Rails root) where the server bundle will be output. + # By default, this is "ssr-generated". If set to nil, the server bundle will be loaded from the same + # public directory as client bundles. For enhanced security, use this option in conjunction with + # `enforce_private_server_bundles` to ensure server bundles are only loaded from private directories + # config.server_bundle_output_path = "ssr-generated" + + # When set to true, React on Rails will only load server bundles from private, explicitly + # configured directories (such as `ssr-generated`), and will raise an error if a server + # bundle is found in a public or untrusted location. This helps prevent accidental or + # malicious execution of untrusted JavaScript on the server, and is strongly recommended + # for production environments. Also prevents leakage of server-side code to the client + # (especially important for React Server Components). + # Default is false for backward compatibility, but enabling this option is a best practice + # for security. + config.enforce_private_server_bundles = false + + ################################################################################ + # BUNDLE ORGANIZATION EXAMPLES + ################################################################################ + # + # This configuration creates a clear separation between client and server assets: + # + # CLIENT BUNDLES (Public, Web-Accessible): + # Location: public/webpack/[environment]/ or public/packs/ (According to your shakapacker.yml configuration) + # Files: application.js, manifest.json, CSS files + # Served by: Web server directly + # Access: ReactOnRails::Utils.public_bundles_full_path + # + # SERVER BUNDLES (Private, Server-Only): + # Location: ssr-generated/ (when server_bundle_output_path configured) + # Files: server-bundle.js, rsc-bundle.js + # Served by: Never served to browsers + # Access: ReactOnRails::Utils.server_bundle_js_file_path + # + # Example directory structure with recommended configuration: + # app/ + # ├── ssr-generated/ # Private server bundles + # │ ├── server-bundle.js + # │ └── rsc-bundle.js + # └── public/ + # └── webpack/development/ # Public client bundles + # ├── application.js + # ├── manifest.json + # └── styles.css + # + ################################################################################ + + # `prerender` means server-side rendering + # default is false. This is an option for view helpers `render_component` and `render_component_hash`. + # Set to true to change the default value to true. + config.prerender = false + + # THE BELOW OPTIONS FOR SERVER-SIDE RENDERING RARELY NEED CHANGING + # + # This value only affects server-side rendering when using the webpack-dev-server + # If you are hashing the server bundle and you want to use the same bundle for client and server, + # you'd set this to `true` so that React on Rails reads the server bundle from the webpack-dev-server. + # Normally, you have different bundles for client and server, thus, the default is false. + # Furthermore, if you are not hashing the server bundle (not in the manifest.json), then React on Rails + # will only look for the server bundle to be created in the typical file location, typically by + # a `shakapacker --watch` process. + # If true, ensure that in config/shakapacker.yml that you have both dev_server.hmr and + # dev_server.inline set to false. + config.same_bundle_for_client_and_server = false + + # If set to true, this forces Rails to reload the server bundle if it is modified + # Default value is Rails.env.development? + # You probably will never change this. + config.development_mode = Rails.env.development? + + # For server rendering so that the server-side console replays in the browser console. + # This can be set to false so that server side messages are not displayed in the browser. + # Default is true. Be cautious about turning this off, as it can make debugging difficult. + # Default value is true + config.replay_console = true + + # Default is true. Logs server rendering messages to Rails.logger.info. If false, you'll only + # see the server rendering messages in the browser console. + config.logging_on_server = true + + # Default is true only for development? to raise exception on server if the JS code throws for + # server rendering. The reason is that the server logs will show the error and force you to fix + # any server rendering issues immediately during development. + config.raise_on_prerender_error = Rails.env.development? + + # This configuration allows logic to be applied to client rendered props, such as stripping props that are only used during server rendering. + # Add a module with an adjust_props_for_client_side_hydration method that expects the component's name & props hash + # See below for an example definition of RenderingPropsExtension + config.rendering_props_extension = RenderingPropsExtension + + ################################################################################ + # Server Renderer Configuration for ExecJS + ################################################################################ + # The default server rendering is ExecJS, by default using Node.js runtime + # If you wish to use an alternative Node server rendering for higher performance, + # contact justin@shakacode.com for details. + # + # For ExecJS: + # You can configure your pool of JS virtual machines and specify where it should load code: + # On MRI, use `node.js` runtime for the best performance + # (see https://github.com/shakacode/react_on_rails/issues/1438) + # Also see https://github.com/shakacode/react_on_rails/issues/1457#issuecomment-1165026717 if using `mini_racer` + # On MRI, you'll get a deadlock with `pool_size` > 1 + # If you're using JRuby, you can increase `pool_size` to have real multi-threaded rendering. + config.server_renderer_pool_size = 1 # increase if you're on JRuby + config.server_renderer_timeout = 20 # seconds + + ################################################################################ + ################################################################################ + # FILE SYSTEM BASED COMPONENT REGISTRY + # `render_component` and `render_component_hash` view helper methods can + # auto-load the bundle for the generated component, to avoid having to specify the + # bundle manually for each view with the component. + # + # SHAKAPACKER VERSION REQUIREMENTS: + # - Basic pack generation: Shakapacker 6.5.1+ + # - Advanced auto-registration with nested entries: Shakapacker 7.0.0+ + # - Async loading support: Shakapacker 8.2.0+ + # + # Feature Compatibility Matrix: + # | Shakapacker Version | Basic Pack Generation | Auto-Registration | Nested Entries | Async Loading | + # |-------------------|----------------------|-------------------|----------------|---------------| + # | 6.5.1 - 6.9.x | ✅ Yes | ❌ No | ❌ No | ❌ No | + # | 7.0.0 - 8.1.x | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | + # | 8.2.0+ | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | + # + ################################################################################ + # components_subdirectory is the name of the subdirectory matched to detect and register components automatically + # The default is nil. You can enable the feature by updating it in the next line. + config.components_subdirectory = nil + # Change to a value like this example to enable this feature + # config.components_subdirectory = "ror_components" + + # Default is false. + # The default can be overridden as an option in calls to view helpers + # `render_component` and `render_component_hash`. You may set to true to change the default to auto loading. + # NOTE: Requires Shakapacker 6.5.1+ for basic functionality, 7.0.0+ for full auto-registration features. + # See version requirements matrix above for complete feature compatibility. + config.auto_load_bundle = false + + # Default is false + # Set this to true & instead of trying to import the generated server components into your existing + # server bundle entrypoint, the PacksGenerator will create a server bundle entrypoint using + # config.server_bundle_js_file for the filename. + config.make_generated_server_bundle_the_entrypoint = false + + # Configuration for how generated component packs are loaded. + # Options: :sync, :async, :defer + # - :sync (default for Shakapacker < 8.2.0): Loads scripts synchronously + # - :async (default for Shakapacker ≥ 8.2.0): Loads scripts asynchronously for better performance + # - :defer: Defers script execution until after page load + config.generated_component_packs_loading_strategy = :async + + # DEPRECATED: Use `generated_component_packs_loading_strategy` instead. + # Migration: `defer_generated_component_packs: true` → `generated_component_packs_loading_strategy: :defer` + # Migration: `defer_generated_component_packs: false` → `generated_component_packs_loading_strategy: :sync` + # See [16.0.0 Release Notes](docs/release-notes/16.0.0.md) for more details. + # config.defer_generated_component_packs = false + + ################################################################################ + # DEPRECATED CONFIGURATION + ################################################################################ + # 🚫 DEPRECATED: immediate_hydration is no longer used + # + # This configuration option has been removed. Immediate hydration is now + # automatically enabled for React on Rails Pro users and cannot be disabled. + # + # If you still have this in your config, it will log a deprecation warning: + # config.immediate_hydration = false # ⚠️ Logs warning, has no effect + # + # Action Required: Remove this line from your config/initializers/react_on_rails.rb + # See CHANGELOG.md for migration instructions. + # + # Historical Context: + # Previously controlled whether Pro components hydrated immediately upon their + # server-rendered HTML reaching the client, vs waiting for full page load. + + ################################################################################ + # I18N OPTIONS + ################################################################################ + # Replace the following line to the location where you keep translation.js & default.js for use + # by the npm packages react-intl. Be sure this directory exists! + # config.i18n_dir = Rails.root.join("client", "app", "libs", "i18n") + # + # If not using the i18n feature, then leave this section commented out or set the value + # of config.i18n_dir to nil. + # + # Replace the following line to the location where you keep your client i18n yml files + # that will source for automatic generation on translations.js & default.js + # By default(without this option) all yaml files from Rails.root.join("config", "locales") + # and installed gems are loaded + config.i18n_yml_dir = Rails.root.join("config", "locales") + + # Possible output formats are js and json + # The default format is json + config.i18n_output_format = 'json' + + # Possible YAML.safe_load options pass-through for locales + # config.i18n_yml_safe_load_options = { permitted_classes: [Symbol] } + + ################################################################################ + ################################################################################ + # TEST CONFIGURATION OPTIONS + # Below options are used with the use of this test helper: + # ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config) + # + # NOTE: + # Instead of using this test helper, you may ensure fresh test files using Shakapacker via: + # 1. Have `config/webpack/test.js` exporting an array of objects to configure both client and server bundles. + # 2. Set the compile option to true in config/shakapacker.yml for env test + ################################################################################ + + # If you are using this in your spec_helper.rb (or rails_helper.rb): + # + # ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config) + # + # with rspec then this controls what yarn command is run + # to automatically refresh your Webpack assets on every test run. + # end ``` diff --git a/docs/core-concepts/webpack-configuration.md b/docs/core-concepts/webpack-configuration.md index 3955b14534..4129111727 100644 --- a/docs/core-concepts/webpack-configuration.md +++ b/docs/core-concepts/webpack-configuration.md @@ -78,6 +78,38 @@ default: &default The `bin/switch-bundler` script automatically updates this configuration when switching bundlers. +### Server Bundle Configuration (Shakapacker 9.0+) + +**Recommended**: For Shakapacker 9.0+, use `private_output_path` in `shakapacker.yml` for server bundles: + +```yaml +default: &default # ... other config ... + private_output_path: ssr-generated +``` + +This provides a single source of truth for server bundle location. React on Rails automatically detects this configuration, eliminating the need to set `server_bundle_output_path` in your React on Rails initializer. + +In your `config/webpack/serverWebpackConfig.js`: + +```javascript +const { config } = require('shakapacker'); + +serverWebpackConfig.output = { + filename: 'server-bundle.js', + globalObject: 'this', + path: config.privateOutputPath, // Automatically uses shakapacker.yml value +}; +``` + +**Benefits:** + +- Single source of truth in `shakapacker.yml` +- Automatic synchronization between webpack and React on Rails +- No configuration duplication +- Better maintainability + +**For older Shakapacker versions:** Use hardcoded paths and manual configuration as shown in the generator templates. + Per the example repo [shakacode/react_on_rails_demo_ssr_hmr](https://github.com/shakacode/react_on_rails_demo_ssr_hmr), you should consider keeping your codebase mostly consistent with the defaults for [Shakapacker](https://github.com/shakacode/shakapacker). diff --git a/lib/generators/react_on_rails/base_generator.rb b/lib/generators/react_on_rails/base_generator.rb index 6f31eca25f..deea5c0ad7 100644 --- a/lib/generators/react_on_rails/base_generator.rb +++ b/lib/generators/react_on_rails/base_generator.rb @@ -101,7 +101,8 @@ def copy_packer_config puts "Adding Shakapacker #{ReactOnRails::PackerUtils.shakapacker_version} config" base_path = "base/base/" config = "config/shakapacker.yml" - copy_file("#{base_path}#{config}", config) + # Use template to enable version-aware configuration + template("#{base_path}#{config}.tt", config) configure_rspack_in_shakapacker if options.rspack? end diff --git a/lib/generators/react_on_rails/generator_helper.rb b/lib/generators/react_on_rails/generator_helper.rb index 1583d94c75..bcb9bf6e57 100644 --- a/lib/generators/react_on_rails/generator_helper.rb +++ b/lib/generators/react_on_rails/generator_helper.rb @@ -95,4 +95,33 @@ def add_documentation_reference(message, source) def component_extension(options) options.typescript? ? "tsx" : "jsx" end + + # Check if Shakapacker 9.0 or higher is available + # Returns true if Shakapacker >= 9.0, false otherwise + # + # This method is used during code generation to determine which configuration + # patterns to use in generated files (e.g., config.privateOutputPath vs hardcoded paths). + # + # @return [Boolean] true if Shakapacker 9.0+ is available or likely to be installed + # + # @note Default behavior: Returns true when Shakapacker is not yet installed + # Rationale: During fresh installations, we optimistically assume users will install + # the latest Shakapacker version. This ensures new projects get best-practice configs. + # If users later install an older version, the generated webpack config includes + # fallback logic (e.g., `config.privateOutputPath || hardcodedPath`) that prevents + # breakage, and validation warnings guide them to fix any misconfigurations. + def shakapacker_version_9_or_higher? + return @shakapacker_version_9_or_higher if defined?(@shakapacker_version_9_or_higher) + + @shakapacker_version_9_or_higher = begin + # If Shakapacker is not available yet (fresh install), default to true + # since we're likely installing the latest version + return true unless defined?(ReactOnRails::PackerUtils) + + ReactOnRails::PackerUtils.shakapacker_version_requirement_met?("9.0.0") + rescue StandardError + # If we can't determine version, assume latest + true + end + end end diff --git a/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt b/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt index e063171d21..7829c17084 100644 --- a/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt +++ b/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt @@ -12,6 +12,25 @@ ReactOnRails.configure do |config| # Set to "" if you're not using server rendering config.server_bundle_js_file = "server-bundle.js" + # ⚠️ RECOMMENDED: Use Shakapacker 9.0+ private_output_path instead + # + # If using Shakapacker 9.0+, add to config/shakapacker.yml: + # private_output_path: ssr-generated + # + # React on Rails will auto-detect this value, eliminating the need to set it here. + # This keeps your webpack and Rails configs in sync automatically. + # + # For older Shakapacker versions or custom setups, manually configure: + # config.server_bundle_output_path = "ssr-generated" + # + # The path is relative to Rails.root and should point to a private directory + # (outside of public/) for security. Run 'rails react_on_rails:doctor' to verify. + + # Enforce that server bundles are only loaded from private (non-public) directories. + # When true, server bundles will only be loaded from the configured server_bundle_output_path. + # This is recommended for production to prevent server-side code from being exposed. + config.enforce_private_server_bundles = true + ################################################################################ # Test Configuration (Optional) ################################################################################ diff --git a/lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml b/lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml.tt similarity index 94% rename from lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml rename to lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml.tt index 26f2db0dbf..f27e97c492 100644 --- a/lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml +++ b/lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml.tt @@ -29,6 +29,15 @@ default: &default # Location for manifest.json, defaults to {public_output_path}/manifest.json if unset # manifest_path: public/packs/manifest.json + # Location for private server-side bundles (e.g., for SSR) + # These bundles are not served publicly, unlike public_output_path + # Shakapacker 9.0+ feature - automatically detected by React on Rails +<% if shakapacker_version_9_or_higher? -%> + private_output_path: ssr-generated +<% else -%> + # private_output_path: ssr-generated # Uncomment to enable (requires Shakapacker 9.0+) +<% end -%> + # Additional paths webpack should look up modules # ['app/assets', 'engine/foo/app/assets'] additional_paths: [] diff --git a/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt b/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt index ec6e527918..e71abf6ed1 100644 --- a/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +++ b/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt @@ -44,19 +44,53 @@ const configureServer = () => { }; serverWebpackConfig.plugins.unshift(new bundler.optimize.LimitChunkCountPlugin({ maxChunks: 1 })); - // Custom output for the server-bundle that matches the config in - // config/initializers/react_on_rails.rb - // Server bundles are output to a private directory (not public) for security + // Custom output for the server-bundle +<% if shakapacker_version_9_or_higher? -%> + // Using Shakapacker 9.0+ privateOutputPath for automatic sync with shakapacker.yml + // This eliminates manual path configuration and keeps configs in sync. + // Falls back to hardcoded path if private_output_path is not configured. + const serverBundleOutputPath = config.privateOutputPath || + require('path').resolve(__dirname, '../../ssr-generated'); +<% else -%> + // Using hardcoded path (Shakapacker < 9.0) + // For Shakapacker 9.0+, consider using config.privateOutputPath instead + // to automatically sync with shakapacker.yml private_output_path. + const serverBundleOutputPath = require('path').resolve(__dirname, '../../ssr-generated'); +<% end -%> + serverWebpackConfig.output = { filename: 'server-bundle.js', globalObject: 'this', // If using the React on Rails Pro node server renderer, uncomment the next line // libraryTarget: 'commonjs2', - path: require('path').resolve(__dirname, '../../ssr-generated'), + path: serverBundleOutputPath, // No publicPath needed since server bundles are not served via web // https://webpack.js.org/configuration/output/#outputglobalobject }; + // Validate server bundle output path configuration +<% if shakapacker_version_9_or_higher? -%> + // For Shakapacker 9.0+, verify privateOutputPath is configured in shakapacker.yml + if (!config.privateOutputPath) { + console.warn('⚠️ Shakapacker 9.0+ detected but private_output_path not configured in shakapacker.yml'); + console.warn(' Add to config/shakapacker.yml:'); + console.warn(' private_output_path: ssr-generated'); + console.warn(' Run: rails react_on_rails:doctor to validate your configuration'); + } +<% else -%> + // For Shakapacker < 9.0, verify hardcoded path syncs with Rails config + // 1. Ensure config/initializers/react_on_rails.rb has: config.server_bundle_output_path = "ssr-generated" + // 2. Run: rails react_on_rails:doctor to verify configuration + const fs = require('fs'); + if (!fs.existsSync(serverBundleOutputPath)) { + console.warn(`⚠️ Server bundle output directory does not exist: ${serverBundleOutputPath}`); + console.warn(' It will be created during build, but ensure React on Rails is configured:'); + console.warn(' config.server_bundle_output_path = "ssr-generated" in config/initializers/react_on_rails.rb'); + console.warn(' Run: rails react_on_rails:doctor to validate your configuration'); + } +<% end -%> + + // Don't hash the server bundle b/c would conflict with the client manifest // And no need for the MiniCssExtractPlugin serverWebpackConfig.plugins = serverWebpackConfig.plugins.filter( diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index bff1d20bca..535032d186 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -10,6 +10,7 @@ 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" def self.configuration @configuration ||= Configuration.new( @@ -46,7 +47,7 @@ def self.configuration # Set to 0 to disable the timeout and wait indefinitely for component registration. component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT, generated_component_packs_loading_strategy: nil, - server_bundle_output_path: "ssr-generated", + server_bundle_output_path: DEFAULT_SERVER_BUNDLE_OUTPUT_PATH, enforce_private_server_bundles: false ) end @@ -184,6 +185,7 @@ def setup_config_values check_component_registry_timeout validate_generated_component_packs_loading_strategy validate_enforce_private_server_bundles + auto_detect_server_bundle_path_from_shakapacker end private @@ -257,6 +259,57 @@ def validate_enforce_private_server_bundles "the public directory. Please set it to a directory outside of public." end + # Auto-detect server_bundle_output_path from Shakapacker 9.0+ private_output_path + # Checks if user explicitly set a value and warns them to use auto-detection instead + def auto_detect_server_bundle_path_from_shakapacker + # Skip if Shakapacker is not available + return unless defined?(::Shakapacker) + + # Check if Shakapacker config has private_output_path method (9.0+) + return unless ::Shakapacker.config.respond_to?(:private_output_path) + + # Get the private_output_path from Shakapacker + private_path = ::Shakapacker.config.private_output_path + return unless private_path + + relative_path = ReactOnRails::Utils.normalize_to_relative_path(private_path) + + # Check if user explicitly configured server_bundle_output_path + if server_bundle_output_path != ReactOnRails::DEFAULT_SERVER_BUNDLE_OUTPUT_PATH + warn_about_explicit_configuration(relative_path) + return + end + + apply_shakapacker_private_output_path(relative_path) + rescue StandardError => e + # Fail gracefully - if auto-detection fails, keep the default + Rails.logger&.debug("ReactOnRails: Could not auto-detect server bundle path from " \ + "Shakapacker: #{e.message}") + end + + def warn_about_explicit_configuration(shakapacker_path) + # Normalize both paths for comparison + normalized_config = server_bundle_output_path.to_s.chomp("/") + normalized_shakapacker = shakapacker_path.to_s.chomp("/") + + # Only warn if there's a mismatch + return if normalized_config == normalized_shakapacker + + Rails.logger&.warn( + "ReactOnRails: server_bundle_output_path is explicitly set to '#{server_bundle_output_path}' " \ + "but shakapacker.yml private_output_path is '#{shakapacker_path}'. " \ + "Consider removing server_bundle_output_path from your React on Rails initializer " \ + "to use the auto-detected value from shakapacker.yml." + ) + end + + def apply_shakapacker_private_output_path(relative_path) + self.server_bundle_output_path = relative_path + + Rails.logger&.debug("ReactOnRails: Auto-detected server_bundle_output_path from " \ + "shakapacker.yml private_output_path: '#{relative_path}'") + end + def check_minimum_shakapacker_version ReactOnRails::PackerUtils.raise_shakapacker_version_incompatible_for_basic_pack_generation unless ReactOnRails::PackerUtils.supports_basic_pack_generation? diff --git a/lib/react_on_rails/doctor.rb b/lib/react_on_rails/doctor.rb index 2ea41b693d..b79a8b3414 100644 --- a/lib/react_on_rails/doctor.rb +++ b/lib/react_on_rails/doctor.rb @@ -667,6 +667,7 @@ def check_react_on_rails_initializer end end + # rubocop:disable Metrics/CyclomaticComplexity def analyze_server_rendering_config(content) checker.add_info("\n🖥️ Server Rendering:") @@ -678,6 +679,19 @@ def analyze_server_rendering_config(content) checker.add_info(" server_bundle_js_file: server-bundle.js (default)") 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 + 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 + + # Check Shakapacker integration and provide recommendations + check_shakapacker_private_output_path(rails_bundle_path) + # RSC bundle file (Pro feature) rsc_bundle_match = content.match(/config\.rsc_bundle_js_file\s*=\s*["']([^"']+)["']/) if rsc_bundle_match @@ -702,9 +716,9 @@ def analyze_server_rendering_config(content) checker.add_info(" raise_on_prerender_error: #{raise_on_error_match[1]}") end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:disable Metrics/CyclomaticComplexity def analyze_performance_config(content) checker.add_info("\n⚡ Performance & Loading:") @@ -1387,9 +1401,85 @@ def config_has_async_loading_strategy? end def log_debug(message) - return unless defined?(Rails.logger) && Rails.logger + Rails.logger&.debug(message) + end + + # Check Shakapacker private_output_path integration and provide recommendations + def check_shakapacker_private_output_path(rails_bundle_path) + return report_no_shakapacker unless defined?(::Shakapacker) + return report_upgrade_shakapacker unless ::Shakapacker.config.respond_to?(:private_output_path) + + check_shakapacker_9_private_output_path(rails_bundle_path) + rescue StandardError => e + checker.add_info("\n ℹ️ Could not check Shakapacker config: #{e.message}") + end + + def report_no_shakapacker + checker.add_info("\n ℹ️ Shakapacker not detected - using manual configuration") + end + + def report_upgrade_shakapacker + checker.add_info(<<~MSG.strip) + \n 💡 Recommendation: Upgrade to Shakapacker 9.0+ + + Shakapacker 9.0+ adds 'private_output_path' in shakapacker.yml for server bundles. + This eliminates the need to configure server_bundle_output_path separately. + + Benefits: + - Single source of truth in shakapacker.yml + - Automatic detection by React on Rails + - No configuration duplication + MSG + end + + def check_shakapacker_9_private_output_path(rails_bundle_path) + private_path = ::Shakapacker.config.private_output_path + + if private_path + report_shakapacker_path_status(private_path, rails_bundle_path) + else + report_configure_private_output_path(rails_bundle_path) + end + end + + def report_shakapacker_path_status(private_path, rails_bundle_path) + relative_path = ReactOnRails::Utils.normalize_to_relative_path(private_path) + # Normalize both paths for comparison (remove trailing slashes) + normalized_relative = relative_path.to_s.chomp("/") + normalized_rails = rails_bundle_path.to_s.chomp("/") + + if normalized_relative == normalized_rails + checker.add_success("\n ✅ Using Shakapacker 9.0+ private_output_path: '#{relative_path}'") + checker.add_info(" Auto-detected from shakapacker.yml - no manual config needed") + else + report_configuration_mismatch(relative_path, rails_bundle_path) + end + end + + def report_configuration_mismatch(relative_path, rails_bundle_path) + checker.add_warning(<<~MSG.strip) + \n ⚠️ Configuration mismatch detected! + + Shakapacker private_output_path: '#{relative_path}' + React on Rails server_bundle_output_path: '#{rails_bundle_path}' + + Recommendation: Remove server_bundle_output_path from your React on Rails + initializer and let it auto-detect from shakapacker.yml private_output_path. + MSG + end + + def report_configure_private_output_path(rails_bundle_path) + checker.add_info(<<~MSG.strip) + \n 💡 Recommendation: Configure private_output_path in shakapacker.yml + + Add to config/shakapacker.yml: + private_output_path: #{rails_bundle_path} - Rails.logger.debug(message) + This will: + - Keep webpack and Rails configs in sync automatically + - Enable auto-detection by React on Rails + - Serve as single source of truth for server bundle location + MSG end end # rubocop:enable Metrics/ClassLength diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index a9d60acbff..264feaae49 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -443,6 +443,60 @@ def self.package_manager_remove_command(package_name) end end + # Converts an absolute path (String or Pathname) to a path relative to Rails.root. + # If the path is already relative or doesn't contain Rails.root, returns it as-is. + # + # This method is used to normalize paths from Shakapacker's privateOutputPath (which is + # absolute) to relative paths suitable for React on Rails configuration. + # + # Note: Absolute paths that don't start with Rails.root are intentionally passed through + # unchanged. While there's no known use case for server bundles outside Rails.root, + # this behavior preserves the original path for debugging and error messages. + # + # @param path [String, Pathname] The path to normalize + # @return [String, nil] The relative path as a string, or nil if path is nil + # + # @example Converting absolute paths within Rails.root + # # Assuming Rails.root is "/app" + # normalize_to_relative_path("/app/ssr-generated") # => "ssr-generated" + # normalize_to_relative_path("/app/foo/bar") # => "foo/bar" + # + # @example Already relative paths pass through + # normalize_to_relative_path("ssr-generated") # => "ssr-generated" + # normalize_to_relative_path("./ssr-generated") # => "./ssr-generated" + # + # @example Absolute paths outside Rails.root (edge case) + # normalize_to_relative_path("/other/path/bundles") # => "/other/path/bundles" + # rubocop:disable Metrics/CyclomaticComplexity + def self.normalize_to_relative_path(path) + return nil if path.nil? + + path_str = path.to_s + rails_root_str = Rails.root.to_s.chomp("/") + + # Treat as "inside Rails.root" only for exact match or a subdirectory + inside_rails_root = rails_root_str.present? && + (path_str == rails_root_str || path_str.start_with?("#{rails_root_str}/")) + + # If path is within Rails.root, remove that prefix + if inside_rails_root + # Remove Rails.root and any leading slash + path_str.sub(%r{^#{Regexp.escape(rails_root_str)}/?}, "") + else + # Path is already relative or outside Rails.root + # Warn if it's an absolute path outside Rails.root (edge case) + if path_str.start_with?("/") && !inside_rails_root + Rails.logger&.warn( + "ReactOnRails: Detected absolute path outside Rails.root: '#{path_str}'. " \ + "Server bundles are typically stored within Rails.root. " \ + "Verify this is intentional." + ) + end + path_str + end + end + # rubocop:enable Metrics/CyclomaticComplexity + def self.default_troubleshooting_section <<~DEFAULT 📞 Get Help & Support: diff --git a/spec/lib/react_on_rails/doctor_spec.rb b/spec/lib/react_on_rails/doctor_spec.rb index aaf6f2192b..1cf90f2ae0 100644 --- a/spec/lib/react_on_rails/doctor_spec.rb +++ b/spec/lib/react_on_rails/doctor_spec.rb @@ -522,4 +522,116 @@ end end end + + describe "server bundle path Shakapacker integration" do + let(:doctor) { described_class.new } + let(:checker) { doctor.instance_variable_get(:@checker) } + + before do + allow(checker).to receive(:add_info) + allow(checker).to receive(:add_success) + allow(checker).to receive(:add_warning) + end + + describe "#check_shakapacker_private_output_path" do + context "when Shakapacker is not defined" do + before do + hide_const("::Shakapacker") + end + + it "reports manual configuration" do + expect(checker).to receive(:add_info).with("\n ℹ️ Shakapacker not detected - using manual configuration") + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + end + + context "when Shakapacker does not support private_output_path (pre-9.0)" do + let(:shakapacker_module) { Module.new } + let(:shakapacker_config) { instance_double(Shakapacker::Configuration) } + + before do + config = shakapacker_config + stub_const("::Shakapacker", shakapacker_module) + shakapacker_module.define_singleton_method(:config) { config } + allow(shakapacker_config).to receive(:respond_to?).with(:private_output_path).and_return(false) + end + + it "recommends upgrading to Shakapacker 9.0+" do + expect(checker).to receive(:add_info).with(/Recommendation: Upgrade to Shakapacker 9\.0\+/) + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + end + + context "when Shakapacker 9.0+ is available" do + let(:shakapacker_module) { Module.new } + let(:shakapacker_config) { instance_double(Shakapacker::Configuration) } + let(:rails_module) { Module.new } + let(:rails_root) { instance_double(Pathname, to_s: "/app") } + + before do + config = shakapacker_config + root = rails_root + stub_const("::Shakapacker", shakapacker_module) + stub_const("Rails", rails_module) + shakapacker_module.define_singleton_method(:config) { config } + rails_module.define_singleton_method(:root) { root } + allow(shakapacker_config).to receive(:respond_to?).with(:private_output_path).and_return(true) + end + + it "reports success when private_output_path matches" do + private_path = instance_double(Pathname, to_s: "/app/ssr-generated") + allow(shakapacker_config).to receive(:private_output_path).and_return(private_path) + + success_msg = "\n ✅ Using Shakapacker 9.0+ private_output_path: 'ssr-generated'" + info_msg = " Auto-detected from shakapacker.yml - no manual config needed" + expect(checker).to receive(:add_success).with(success_msg) + expect(checker).to receive(:add_info).with(info_msg) + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + + it "warns when private_output_path doesn't match" do + private_path = instance_double(Pathname, to_s: "/app/server-bundles") + allow(shakapacker_config).to receive(:private_output_path).and_return(private_path) + + expect(checker).to receive(:add_warning).with(/Configuration mismatch detected/) + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + + it "includes both paths in mismatch warning" do + private_path = instance_double(Pathname, to_s: "/app/server-bundles") + allow(shakapacker_config).to receive(:private_output_path).and_return(private_path) + + expect(checker).to receive(:add_warning) do |msg| + expect(msg).to include("Shakapacker private_output_path: 'server-bundles'") + expect(msg).to include("React on Rails server_bundle_output_path: 'ssr-generated'") + end + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + + it "recommends configuring when private_output_path not set" do + allow(shakapacker_config).to receive(:private_output_path).and_return(nil) + + recommendation_msg = /Recommendation: Configure private_output_path in shakapacker\.yml/ + expect(checker).to receive(:add_info).with(recommendation_msg) + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + + it "provides configuration example when not set" do + allow(shakapacker_config).to receive(:private_output_path).and_return(nil) + + expect(checker).to receive(:add_info) do |msg| + expect(msg).to include("private_output_path: ssr-generated") + end + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + + it "handles errors gracefully" do + allow(shakapacker_config).to receive(:private_output_path).and_raise(StandardError, "Config error") + + expect(checker).to receive(:add_info).with("\n ℹ️ Could not check Shakapacker config: Config error") + doctor.send(:check_shakapacker_private_output_path, "ssr-generated") + end + end + end + end end diff --git a/spec/react_on_rails/configuration_spec.rb b/spec/react_on_rails/configuration_spec.rb index 7d90b7ffcc..2e74bc0c6f 100644 --- a/spec/react_on_rails/configuration_spec.rb +++ b/spec/react_on_rails/configuration_spec.rb @@ -550,6 +550,82 @@ module ReactOnRails end end end + + describe "auto_detect_server_bundle_path_from_shakapacker" do + let(:shakapacker_module) { Module.new } + let(:shakapacker_config) { double("ShakapackerConfig") } # rubocop:disable RSpec/VerifiedDoubles + + before do + config = shakapacker_config + stub_const("::Shakapacker", shakapacker_module) + shakapacker_module.define_singleton_method(:config) { config } + allow(shakapacker_config).to receive(:respond_to?).with(:private_output_path).and_return(true) + end + + context "when user explicitly set server_bundle_output_path to different value" do + it "warns about configuration mismatch" do + allow(shakapacker_config).to receive(:private_output_path) + .and_return(Pathname.new("/fake/rails/root/shakapacker-bundles")) + + expect(Rails.logger).to receive(:warn).with( + /server_bundle_output_path is explicitly set.*shakapacker\.yml private_output_path/ + ) + + ReactOnRails.configure do |config| + config.server_bundle_output_path = "custom-path" + end + end + + it "does not warn when paths match after normalization" do + allow(shakapacker_config).to receive(:private_output_path) + .and_return(Pathname.new("/fake/rails/root/ssr-generated")) + + expect(Rails.logger).not_to receive(:warn) + + ReactOnRails.configure do |config| + config.server_bundle_output_path = "ssr-generated" + end + end + end + + context "when user has not explicitly set server_bundle_output_path" do + it "auto-detects from Shakapacker private_output_path" do + allow(shakapacker_config).to receive(:private_output_path) + .and_return(Pathname.new("/fake/rails/root/shakapacker-bundles")) + + config = nil + ReactOnRails.configure do |c| + config = c + end + + expect(config.server_bundle_output_path).to eq("shakapacker-bundles") + end + + it "logs debug message on successful auto-detection" do + allow(shakapacker_config).to receive(:private_output_path) + .and_return(Pathname.new("/fake/rails/root/auto-detected")) + + expect(Rails.logger).to receive(:debug).with( + /Auto-detected server_bundle_output_path.*auto-detected/ + ) + + ReactOnRails.configure { |_config| } # rubocop:disable Lint/EmptyBlock + end + end + + context "when Shakapacker private_output_path is nil" do + it "keeps default value" do + allow(shakapacker_config).to receive(:private_output_path).and_return(nil) + + config = nil + ReactOnRails.configure do |c| + config = c + end + + expect(config.server_bundle_output_path).to eq("ssr-generated") + end + end + end end end diff --git a/spec/react_on_rails/utils_spec.rb b/spec/react_on_rails/utils_spec.rb index 908f00d958..db87c68cd0 100644 --- a/spec/react_on_rails/utils_spec.rb +++ b/spec/react_on_rails/utils_spec.rb @@ -899,6 +899,117 @@ def self.configuration=(config) # RSC utility method tests moved to react_on_rails_pro/spec/react_on_rails_pro/utils_spec.rb + describe ".normalize_to_relative_path" do + let(:rails_root) { "/app" } + + before do + allow(Rails).to receive(:root).and_return(Pathname.new(rails_root)) + end + + context "with absolute path containing Rails.root" do + it "removes Rails.root prefix" do + expect(described_class.normalize_to_relative_path("/app/ssr-generated")) + .to eq("ssr-generated") + end + + it "handles paths with trailing slash in Rails.root" do + expect(described_class.normalize_to_relative_path("/app/ssr-generated/nested")) + .to eq("ssr-generated/nested") + end + + it "removes leading slash after Rails.root" do + allow(Rails).to receive(:root).and_return(Pathname.new("/app/")) + expect(described_class.normalize_to_relative_path("/app/ssr-generated")) + .to eq("ssr-generated") + end + end + + context "with Pathname object" do + it "converts Pathname to relative string" do + path = Pathname.new("/app/ssr-generated") + expect(described_class.normalize_to_relative_path(path)) + .to eq("ssr-generated") + end + + it "handles already relative Pathname" do + path = Pathname.new("ssr-generated") + expect(described_class.normalize_to_relative_path(path)) + .to eq("ssr-generated") + end + end + + context "with already relative path" do + it "returns the path unchanged" do + expect(described_class.normalize_to_relative_path("ssr-generated")) + .to eq("ssr-generated") + end + + it "handles nested relative paths" do + expect(described_class.normalize_to_relative_path("config/ssr-generated")) + .to eq("config/ssr-generated") + end + + it "handles paths with . prefix" do + expect(described_class.normalize_to_relative_path("./ssr-generated")) + .to eq("./ssr-generated") + end + end + + context "with nil path" do + it "returns nil" do + expect(described_class.normalize_to_relative_path(nil)).to be_nil + end + end + + context "with absolute path not containing Rails.root" do + it "returns path unchanged" do + expect(described_class.normalize_to_relative_path("/other/path/ssr-generated")) + .to eq("/other/path/ssr-generated") + end + + it "logs warning for absolute path outside Rails.root" do + expect(Rails.logger).to receive(:warn).with( + %r{ReactOnRails: Detected absolute path outside Rails\.root: '/other/path/ssr-generated'} + ) + described_class.normalize_to_relative_path("/other/path/ssr-generated") + end + + it "does not warn for relative paths" do + expect(Rails.logger).not_to receive(:warn) + described_class.normalize_to_relative_path("ssr-generated") + end + end + + context "with path containing Rails.root as substring" do + it "only removes Rails.root prefix, not substring matches" do + allow(Rails).to receive(:root).and_return(Pathname.new("/app")) + # Path contains "/app" but not as prefix + expect(described_class.normalize_to_relative_path("/myapp/ssr-generated")) + .to eq("/myapp/ssr-generated") + end + end + + context "with complex Rails.root paths" do + it "handles Rails.root with special characters" do + allow(Rails).to receive(:root).and_return(Pathname.new("/home/user/my-app")) + expect(described_class.normalize_to_relative_path("/home/user/my-app/ssr-generated")) + .to eq("ssr-generated") + end + + it "handles Rails.root with spaces" do + allow(Rails).to receive(:root).and_return(Pathname.new("/home/user/my app")) + expect(described_class.normalize_to_relative_path("/home/user/my app/ssr-generated")) + .to eq("ssr-generated") + end + + it "handles Rails.root with dots" do + allow(Rails).to receive(:root).and_return(Pathname.new("/home/user/app.v2")) + expect(described_class.normalize_to_relative_path("/home/user/app.v2/ssr-generated")) + .to eq("ssr-generated") + end + end + end + describe ".normalize_immediate_hydration" do context "with Pro license" do before do