diff --git a/docs/api-reference/configuration.md b/docs/api-reference/configuration.md index 19e6fd9549..5dba40d692 100644 --- a/docs/api-reference/configuration.md +++ b/docs/api-reference/configuration.md @@ -94,6 +94,234 @@ 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. And prevent leakage of server-side code to the client (Especially in the case of RSC). + # 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 + + # Default is false + # React on Rails Pro (licensed) feature: When true, components hydrate immediately as soon as + # their server-rendered HTML reaches the client, without waiting for the full page load. + # This improves time-to-interactive performance. + config.immediate_hydration = false + + ################################################################################ + # 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 8558705088..6cda8793fa 100644 --- a/lib/generators/react_on_rails/base_generator.rb +++ b/lib/generators/react_on_rails/base_generator.rb @@ -99,7 +99,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..b569eb7c2d 100644 --- a/lib/generators/react_on_rails/generator_helper.rb +++ b/lib/generators/react_on_rails/generator_helper.rb @@ -95,4 +95,21 @@ 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 + 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 8015f38971..4672697c51 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 93% 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 30e290fcbc..75bea4166b 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,13 @@ 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 %> + # Uncomment to enable (requires Shakapacker 9.0+): + # private_output_path: ssr-generated<% 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..207c5626f4 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,9 +44,22 @@ 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. + 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: config.privateOutputPath, + // No publicPath needed since server bundles are not served via web + // https://webpack.js.org/configuration/output/#outputglobalobject + };<% 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. serverWebpackConfig.output = { filename: 'server-bundle.js', globalObject: 'this', @@ -55,7 +68,7 @@ const configureServer = () => { path: require('path').resolve(__dirname, '../../ssr-generated'), // No publicPath needed since server bundles are not served via web // https://webpack.js.org/configuration/output/#outputglobalobject - }; + };<% end %> // Don't hash the server bundle b/c would conflict with the client manifest // And no need for the MiniCssExtractPlugin diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index bff1d20bca..1447c5d218 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -184,6 +184,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 +258,37 @@ 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 + # Only sets if user hasn't explicitly configured server_bundle_output_path + # rubocop:disable Metrics/CyclomaticComplexity + def auto_detect_server_bundle_path_from_shakapacker + # Skip if user explicitly set server_bundle_output_path to something other than default + return if server_bundle_output_path != "ssr-generated" + + # 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) + + begin + private_path = ::Shakapacker.config.private_output_path + return unless private_path + + # Convert from Pathname to relative string path + relative_path = ReactOnRails::Utils.normalize_to_relative_path(private_path) + self.server_bundle_output_path = relative_path + + Rails.logger&.info("ReactOnRails: Auto-detected server_bundle_output_path from " \ + "shakapacker.yml 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 + end + # rubocop:enable Metrics/CyclomaticComplexity + 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..c0ab50e587 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,18 @@ 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*["']([^"']+)["']/) + rails_bundle_path = server_bundle_path_match ? server_bundle_path_match[1] : "ssr-generated" + 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,7 +715,7 @@ 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/AbcSize, Metrics/CyclomaticComplexity # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity def analyze_performance_config(content) @@ -1391,6 +1404,70 @@ def log_debug(message) Rails.logger.debug(message) end + + # Check Shakapacker private_output_path integration and provide recommendations + # rubocop:disable Metrics/MethodLength + def check_shakapacker_private_output_path(rails_bundle_path) + unless defined?(::Shakapacker) + checker.add_info("\n ℹ️ Shakapacker not detected - using manual configuration") + return + end + + # Check if Shakapacker 9.0+ with private_output_path support + unless ::Shakapacker.config.respond_to?(:private_output_path) + 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 + return + end + + # Shakapacker 9.0+ is available + begin + private_path = ::Shakapacker.config.private_output_path + + if private_path + relative_path = ReactOnRails::Utils.normalize_to_relative_path(private_path) + + if relative_path == rails_bundle_path + 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 + 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 + else + 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} + + 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 + rescue StandardError => e + checker.add_info("\n ℹ️ Could not check Shakapacker config: #{e.message}") + end + end + # rubocop:enable Metrics/MethodLength end # rubocop:enable Metrics/ClassLength end diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index a9d60acbff..340d4f0e3b 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -443,6 +443,31 @@ 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. + # + # @param path [String, Pathname] The path to normalize + # @return [String] The relative path as a string + # + # @example + # normalize_to_relative_path("/app/ssr-generated") # => "ssr-generated" + # normalize_to_relative_path("ssr-generated") # => "ssr-generated" + def self.normalize_to_relative_path(path) + return nil if path.nil? + + path_str = path.to_s + rails_root_str = Rails.root.to_s + + # If path starts with Rails.root, remove that prefix + if path_str.start_with?(rails_root_str) + # Remove Rails.root and any leading slash + path_str.sub(%r{^#{Regexp.escape(rails_root_str)}/?}, "") + else + # Path is already relative or doesn't contain Rails.root + path_str + end + end + def self.default_troubleshooting_section <<~DEFAULT 📞 Get Help & Support: diff --git a/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb b/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb index 9d7a3e2206..fac30d9d27 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/license_public_key.rb @@ -16,15 +16,15 @@ module LicensePublicKey # TODO: Add a prepublish check to ensure this key matches the latest public key from the API. # This should be implemented after publishing the API endpoint on the ShakaCode website. KEY = OpenSSL::PKey::RSA.new(<<~PEM.strip.strip_heredoc) - -----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzcS/fpHz5CbnTQxb4Zot -khjzXu7xNS+Y9VKfapMaHOMzNoCMfy1++hxHJatRedr+YQfZRCjfiN168Cpe+dhe -yfNtOoLU9/+/5jTsxH+WQJWNRswyKms5HNajlIMN1GEYdZmZbvOPaZvh6ENsT+EV -HnhjJtsHl7qltBoL0ul7rONxaNHCzJcKk4lf3B2/1j1wpA91MKz4bbQVh4/6Th0E -/39f0PWvvBXzQS+yt1qaa1DIX5YL6Aug5uEpb1+6QWcN3hCzqSPBv1HahrG50rsD -gf8KORV3X2N9t6j6iqPmRqfRcTBKtmPhM9bORtKiSwBK8LsIUzp2/UUmkdHnkyzu -NQIDAQAB ------END PUBLIC KEY----- + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzcS/fpHz5CbnTQxb4Zot + khjzXu7xNS+Y9VKfapMaHOMzNoCMfy1++hxHJatRedr+YQfZRCjfiN168Cpe+dhe + yfNtOoLU9/+/5jTsxH+WQJWNRswyKms5HNajlIMN1GEYdZmZbvOPaZvh6ENsT+EV + HnhjJtsHl7qltBoL0ul7rONxaNHCzJcKk4lf3B2/1j1wpA91MKz4bbQVh4/6Th0E + /39f0PWvvBXzQS+yt1qaa1DIX5YL6Aug5uEpb1+6QWcN3hCzqSPBv1HahrG50rsD + gf8KORV3X2N9t6j6iqPmRqfRcTBKtmPhM9bORtKiSwBK8LsIUzp2/UUmkdHnkyzu + NQIDAQAB + -----END PUBLIC KEY----- PEM end end diff --git a/react_on_rails_pro/lib/react_on_rails_pro/request.rb b/react_on_rails_pro/lib/react_on_rails_pro/request.rb index cfe2c7ed53..cae199d623 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/request.rb @@ -229,28 +229,28 @@ def create_connection # https://honeyryderchuck.gitlab.io/httpx/wiki/Persistent .plugin( :retries, max_retries: 1, - retry_change_requests: true, - # Official HTTPx docs says that we should use the retry_on option to decide if teh request should be retried or not - # However, HTTPx assumes that connection errors such as timeout error should be retried by default and it doesn't consider retry_on block at all at that case - # So, we have to do the following trick to avoid retries when a Timeout error happens while streaming a component - # If the streamed component returned any chunks, it shouldn't retry on errors, as it would cause page duplication - # The SSR-generated html will be written to the page two times in this case - retry_after: ->(request, response) do - if (request.stream.instance_variable_get(:@react_on_rails_received_first_chunk)) - e = response.error - raise ReactOnRailsPro::Error, "An error happened during server side render streaming of a component.\n" \ - "Original error:\n#{e}\n#{e.backtrace}" - end - - Rails.logger.info do - "[ReactOnRailsPro] An error happneding while making a request to the Node Renderer.\n" \ - "Error: #{response.error}.\n" \ - "Retrying by HTTPX \"retries\" plugin..." - end - # The retry_after block expects to return a delay to wait before retrying the request - # nil means no waiting delay - nil - end + retry_change_requests: true, + # Official HTTPx docs says that we should use the retry_on option to decide if teh request should be retried or not + # However, HTTPx assumes that connection errors such as timeout error should be retried by default and it doesn't consider retry_on block at all at that case + # So, we have to do the following trick to avoid retries when a Timeout error happens while streaming a component + # If the streamed component returned any chunks, it shouldn't retry on errors, as it would cause page duplication + # The SSR-generated html will be written to the page two times in this case + retry_after: lambda do |request, response| + if request.stream.instance_variable_get(:@react_on_rails_received_first_chunk) + e = response.error + raise ReactOnRailsPro::Error, "An error happened during server side render streaming of a component.\n" \ + "Original error:\n#{e}\n#{e.backtrace}" + end + + Rails.logger.info do + "[ReactOnRailsPro] An error happneding while making a request to the Node Renderer.\n" \ + "Error: #{response.error}.\n" \ + "Retrying by HTTPX \"retries\" plugin..." + end + # The retry_after block expects to return a delay to wait before retrying the request + # nil means no waiting delay + nil + end ) .plugin(:stream) # See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options diff --git a/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb b/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb index e0accae5cb..78d8555c23 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb @@ -58,16 +58,16 @@ def each_chunk(&block) @component.each_chunk do |chunk| position = first_chunk ? :first : :middle modified_chunk = handle_chunk(chunk, position) - block.call(modified_chunk) + yield(modified_chunk) first_chunk = false end # The last chunk contains the append content after the transformation # All transformations are applied to the append content last_chunk = handle_chunk("", :last) - block.call(last_chunk) unless last_chunk.empty? - rescue StandardError => err - current_error = err + yield(last_chunk) unless last_chunk.empty? + rescue StandardError => e + current_error = e rescue_block_index = 0 while current_error.present? && (rescue_block_index < @rescue_blocks.size) begin diff --git a/react_on_rails_pro/spec/react_on_rails_pro/stream_decorator_spec.rb b/react_on_rails_pro/spec/react_on_rails_pro/stream_decorator_spec.rb index d22f321a9f..be352223ec 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/stream_decorator_spec.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/stream_decorator_spec.rb @@ -65,7 +65,7 @@ describe "#rescue" do it "catches the error happens inside the component" do - allow(mock_component).to receive(:each_chunk).and_raise(StandardError.new "Fake Error") + allow(mock_component).to receive(:each_chunk).and_raise(StandardError.new("Fake Error")) mocked_block = mock_block stream_decorator.rescue(&mocked_block.block) @@ -80,7 +80,7 @@ end it "catches the error happens inside subsequent component calls" do - allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(ArgumentError.new "Fake Error") + allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(ArgumentError.new("Fake Error")) mocked_block = mock_block stream_decorator.rescue(&mocked_block.block) @@ -96,7 +96,7 @@ end it "can yield values to the stream" do - allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(ArgumentError.new "Fake Error") + allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(ArgumentError.new("Fake Error")) mocked_block = mock_block stream_decorator.rescue(&mocked_block.block) @@ -115,11 +115,11 @@ end it "can convert the error into another error" do - allow(mock_component).to receive(:each_chunk).and_raise(StandardError.new "Fake Error") + allow(mock_component).to receive(:each_chunk).and_raise(StandardError.new("Fake Error")) mocked_block = mock_block do |error| expect(error).to be_a(StandardError) expect(error.message).to eq("Fake Error") - raise ArgumentError.new "Another Error" + raise ArgumentError, "Another Error" end stream_decorator.rescue(&mocked_block.block) @@ -129,12 +129,12 @@ end it "chains multiple rescue blocks" do - allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(StandardError.new "Fake Error") + allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(StandardError.new("Fake Error")) fist_rescue_block = mock_block do |error, &block| expect(error).to be_a(StandardError) expect(error.message).to eq("Fake Error") block.call "Chunk from first rescue block" - raise ArgumentError.new "Another Error" + raise ArgumentError, "Another Error" end second_rescue_block = mock_block do |error, &block| diff --git a/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb b/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb index 7b00141163..1e1330e562 100644 --- a/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb +++ b/react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb @@ -10,9 +10,9 @@ module MockBlockHelper # testing_method_taking_block(&mocked_block.block) # expect(mocked_block).to have_received(:call).with(1, 2, 3) def mock_block(&block) - double("BlockMock").tap do |mock| # rubocop:disable RSpec/VerifiedDoubles + double("BlockMock").tap do |mock| allow(mock).to receive(:call) do |*args, &inner_block| - block.call(*args, &inner_block) if block + block&.call(*args, &inner_block) end def mock.block method(:call).to_proc diff --git a/spec/lib/react_on_rails/doctor_spec.rb b/spec/lib/react_on_rails/doctor_spec.rb index aaf6f2192b..1d38aa48a2 100644 --- a/spec/lib/react_on_rails/doctor_spec.rb +++ b/spec/lib/react_on_rails/doctor_spec.rb @@ -268,6 +268,118 @@ 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 + describe "#scan_view_files_for_async_pack_tag" do before do allow(Dir).to receive(:glob).and_call_original diff --git a/spec/react_on_rails/utils_spec.rb b/spec/react_on_rails/utils_spec.rb index 908f00d958..f47479d4f7 100644 --- a/spec/react_on_rails/utils_spec.rb +++ b/spec/react_on_rails/utils_spec.rb @@ -899,6 +899,105 @@ 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 + 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