diff --git a/CHANGELOG.md b/CHANGELOG.md index 9866cbead7..db7d353055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ Changes since the last non-beta release. - **Generator Configuration Modernization**: Updated the generator to enable recommended configurations by default for new applications. `config.build_test_command` is now uncommented and set to `"RAILS_ENV=test bin/shakapacker"` by default, enabling automatic asset building during tests for better integration test reliability. `config.auto_load_bundle = true` is now set by default, enabling automatic loading of component bundles. `config.components_subdirectory = "ror_components"` is now set by default, organizing React components in a dedicated subdirectory. **Note:** These changes only affect newly generated applications. Existing applications are unaffected and do not need to make any changes. If you want to adopt these settings in an existing app, you can manually add them to your `config/initializers/react_on_rails.rb` file. [PR 2039](https://github.com/shakacode/react_on_rails/pull/2039) by [justin808](https://github.com/justin808). +- **Removed Babel Dependency Installation**: The generator no longer installs `@babel/preset-react` or `@babel/preset-typescript` packages. Shakapacker handles JavaScript transpiler configuration (Babel, SWC, or esbuild) via the `javascript_transpiler` setting in `shakapacker.yml`. SWC is now the default transpiler and includes built-in support for React and TypeScript. Users who explicitly choose Babel will need to manually install and configure the required presets. This change reduces unnecessary dependencies and aligns with Shakapacker's modular transpiler approach. [PR 2051](https://github.com/shakacode/react_on_rails/pull/2051) by [justin808](https://github.com/justin808). + #### Documentation - **Simplified Configuration Files**: Improved configuration documentation and generator template for better clarity and usability. Reduced generator template from 67 to 42 lines (37% reduction). Added comprehensive testing configuration guide. Reorganized configuration docs into Essential vs Advanced sections. Enhanced Doctor program with diagnostics for server rendering and test compilation consistency. [PR #2011](https://github.com/shakacode/react_on_rails/pull/2011) by [justin808](https://github.com/justin808). diff --git a/lib/generators/react_on_rails/base_generator.rb b/lib/generators/react_on_rails/base_generator.rb index 8558705088..6f31eca25f 100644 --- a/lib/generators/react_on_rails/base_generator.rb +++ b/lib/generators/react_on_rails/base_generator.rb @@ -4,10 +4,12 @@ require "fileutils" require_relative "generator_messages" require_relative "generator_helper" +require_relative "js_dependency_manager" module ReactOnRails module Generators class BaseGenerator < Rails::Generators::Base include GeneratorHelper + include JsDependencyManager Rails::Generators.hide_namespace(namespace) source_root(File.expand_path("templates", __dir__)) @@ -107,7 +109,7 @@ def add_base_gems_to_gemfile run "bundle" end - def update_gitignore_for_generated_bundles + def update_gitignore_for_auto_registration gitignore_path = File.join(destination_root, ".gitignore") return unless File.exist?(gitignore_path) @@ -146,123 +148,6 @@ def append_to_spec_rails_helper private - def setup_js_dependencies - add_js_dependencies - install_js_dependencies - end - - def add_js_dependencies - add_react_on_rails_package - add_react_dependencies - add_css_dependencies - add_dev_dependencies - end - - def add_react_on_rails_package - major_minor_patch_only = /\A\d+\.\d+\.\d+\z/ - - # Try to use package_json gem first, fall back to direct npm commands - react_on_rails_pkg = if ReactOnRails::VERSION.match?(major_minor_patch_only) - ["react-on-rails@#{ReactOnRails::VERSION}"] - else - puts "Adding the latest react-on-rails NPM module. " \ - "Double check this is correct in package.json" - ["react-on-rails"] - end - - puts "Installing React on Rails package..." - return if add_npm_dependencies(react_on_rails_pkg) - - puts "Using direct npm commands as fallback" - success = system("npm", "install", *react_on_rails_pkg) - handle_npm_failure("react-on-rails package", react_on_rails_pkg) unless success - end - - def add_react_dependencies - puts "Installing React dependencies..." - react_deps = %w[ - react - react-dom - @babel/preset-react - prop-types - babel-plugin-transform-react-remove-prop-types - babel-plugin-macros - ] - return if add_npm_dependencies(react_deps) - - success = system("npm", "install", *react_deps) - handle_npm_failure("React dependencies", react_deps) unless success - end - - def add_css_dependencies - puts "Installing CSS handling dependencies..." - css_deps = %w[ - css-loader - css-minimizer-webpack-plugin - mini-css-extract-plugin - style-loader - ] - return if add_npm_dependencies(css_deps) - - success = system("npm", "install", *css_deps) - handle_npm_failure("CSS dependencies", css_deps) unless success - end - - def add_dev_dependencies - puts "Installing development dependencies..." - dev_deps = %w[ - @pmmmwh/react-refresh-webpack-plugin - react-refresh - ] - return if add_npm_dependencies(dev_deps, dev: true) - - success = system("npm", "install", "--save-dev", *dev_deps) - handle_npm_failure("development dependencies", dev_deps, dev: true) unless success - end - - def install_js_dependencies - # Detect which package manager to use - success = if File.exist?(File.join(destination_root, "yarn.lock")) - system("yarn", "install") - elsif File.exist?(File.join(destination_root, "pnpm-lock.yaml")) - system("pnpm", "install") - elsif File.exist?(File.join(destination_root, "package-lock.json")) || - File.exist?(File.join(destination_root, "package.json")) - # Use npm for package-lock.json or as default fallback - system("npm", "install") - else - true # No package manager detected, skip - end - - unless success - GeneratorMessages.add_warning(<<~MSG.strip) - ⚠️ JavaScript dependencies installation failed. - - This could be due to network issues or missing package manager. - You can install dependencies manually later by running: - • npm install (if using npm) - • yarn install (if using yarn) - • pnpm install (if using pnpm) - MSG - end - - success - end - - def handle_npm_failure(dependency_type, packages, dev: false) - install_command = dev ? "npm install --save-dev" : "npm install" - GeneratorMessages.add_warning(<<~MSG.strip) - ⚠️ Failed to install #{dependency_type}. - - The following packages could not be installed automatically: - #{packages.map { |pkg| " • #{pkg}" }.join("\n")} - - This could be due to network issues or missing package manager. - You can install them manually later by running: - #{install_command} #{packages.join(' ')} - MSG - end - def copy_webpack_main_config(base_path, config) webpack_config_path = "config/webpack/webpack.config.js" diff --git a/lib/generators/react_on_rails/install_generator.rb b/lib/generators/react_on_rails/install_generator.rb index f39168bc37..32cfd565a4 100644 --- a/lib/generators/react_on_rails/install_generator.rb +++ b/lib/generators/react_on_rails/install_generator.rb @@ -4,12 +4,14 @@ require "json" require_relative "generator_helper" require_relative "generator_messages" +require_relative "js_dependency_manager" module ReactOnRails module Generators # rubocop:disable Metrics/ClassLength class InstallGenerator < Rails::Generators::Base include GeneratorHelper + include JsDependencyManager # fetch USAGE file for details generator description source_root(File.expand_path(__dir__)) @@ -113,10 +115,7 @@ def invoke_generators end def setup_react_dependencies - @added_dependencies_to_package_json ||= false - @ran_direct_installs ||= false - add_js_dependencies - install_js_dependencies if @added_dependencies_to_package_json && !@ran_direct_installs + setup_js_dependencies end # NOTE: other requirements for existing files such as .gitignore or application. @@ -366,29 +365,8 @@ def missing_package_manager? def install_typescript_dependencies puts Rainbow("📝 Installing TypeScript dependencies...").yellow - - # Install TypeScript and React type definitions - typescript_packages = %w[ - typescript - @types/react - @types/react-dom - @babel/preset-typescript - ] - - # Try using GeneratorHelper first (package manager agnostic) - return if add_npm_dependencies(typescript_packages, dev: true) - - # Fallback to npm if GeneratorHelper fails - success = system("npm", "install", "--save-dev", *typescript_packages) - return if success - - warning = <<~MSG.strip - ⚠️ Failed to install TypeScript dependencies automatically. - - Please run manually: - npm install --save-dev #{typescript_packages.join(' ')} - MSG - GeneratorMessages.add_warning(warning) + # Delegate to shared module for consistent dependency management + add_typescript_dependencies end def create_css_module_types @@ -450,159 +428,6 @@ def create_typescript_config puts Rainbow("✅ Created tsconfig.json").green end - def add_js_dependencies - add_react_on_rails_package - add_react_dependencies - add_css_dependencies - add_rspack_dependencies if options.rspack? - add_dev_dependencies - end - - def add_react_on_rails_package - major_minor_patch_only = /\A\d+\.\d+\.\d+\z/ - - # Try to use package_json gem first, fall back to direct npm commands - react_on_rails_pkg = if ReactOnRails::VERSION.match?(major_minor_patch_only) - ["react-on-rails@#{ReactOnRails::VERSION}"] - else - puts "Adding the latest react-on-rails NPM module. " \ - "Double check this is correct in package.json" - ["react-on-rails"] - end - - puts "Installing React on Rails package..." - if add_npm_dependencies(react_on_rails_pkg) - @added_dependencies_to_package_json = true - return - end - - puts "Using direct npm commands as fallback" - success = system("npm", "install", *react_on_rails_pkg) - @ran_direct_installs = true if success - handle_npm_failure("react-on-rails package", react_on_rails_pkg) unless success - end - - def add_react_dependencies - puts "Installing React dependencies..." - react_deps = %w[ - react - react-dom - @babel/preset-react - prop-types - babel-plugin-transform-react-remove-prop-types - babel-plugin-macros - ] - if add_npm_dependencies(react_deps) - @added_dependencies_to_package_json = true - return - end - - success = system("npm", "install", *react_deps) - @ran_direct_installs = true if success - handle_npm_failure("React dependencies", react_deps) unless success - end - - def add_css_dependencies - puts "Installing CSS handling dependencies..." - css_deps = %w[ - css-loader - css-minimizer-webpack-plugin - mini-css-extract-plugin - style-loader - ] - if add_npm_dependencies(css_deps) - @added_dependencies_to_package_json = true - return - end - - success = system("npm", "install", *css_deps) - @ran_direct_installs = true if success - handle_npm_failure("CSS dependencies", css_deps) unless success - end - - def add_rspack_dependencies - puts "Installing Rspack core dependencies..." - rspack_deps = %w[ - @rspack/core - rspack-manifest-plugin - ] - if add_npm_dependencies(rspack_deps) - @added_dependencies_to_package_json = true - return - end - - success = system("npm", "install", *rspack_deps) - @ran_direct_installs = true if success - handle_npm_failure("Rspack dependencies", rspack_deps) unless success - end - - def add_dev_dependencies - puts "Installing development dependencies..." - dev_deps = if options.rspack? - %w[ - @rspack/cli - @rspack/plugin-react-refresh - react-refresh - ] - else - %w[ - @pmmmwh/react-refresh-webpack-plugin - react-refresh - ] - end - if add_npm_dependencies(dev_deps, dev: true) - @added_dependencies_to_package_json = true - return - end - - success = system("npm", "install", "--save-dev", *dev_deps) - @ran_direct_installs = true if success - handle_npm_failure("development dependencies", dev_deps, dev: true) unless success - end - - def install_js_dependencies - # Detect which package manager to use - success = if File.exist?(File.join(destination_root, "yarn.lock")) - system("yarn", "install") - elsif File.exist?(File.join(destination_root, "pnpm-lock.yaml")) - system("pnpm", "install") - elsif File.exist?(File.join(destination_root, "package-lock.json")) || - File.exist?(File.join(destination_root, "package.json")) - # Use npm for package-lock.json or as default fallback - system("npm", "install") - else - true # No package manager detected, skip - end - - unless success - GeneratorMessages.add_warning(<<~MSG.strip) - ⚠️ JavaScript dependencies installation failed. - - This could be due to network issues or missing package manager. - You can install dependencies manually later by running: - • npm install (if using npm) - • yarn install (if using yarn) - • pnpm install (if using pnpm) - MSG - end - - success - end - - def handle_npm_failure(dependency_type, packages, dev: false) - install_command = dev ? "npm install --save-dev" : "npm install" - GeneratorMessages.add_warning(<<~MSG.strip) - ⚠️ Failed to install #{dependency_type}. - - The following packages could not be installed automatically: - #{packages.map { |pkg| " • #{pkg}" }.join("\n")} - - This could be due to network issues or missing package manager. - You can install them manually later by running: - #{install_command} #{packages.join(' ')} - MSG - end - # Removed: Shakapacker auto-installation logic (now explicit dependency) # Removed: Shakapacker 8+ is now required as explicit dependency diff --git a/lib/generators/react_on_rails/js_dependency_manager.rb b/lib/generators/react_on_rails/js_dependency_manager.rb new file mode 100644 index 0000000000..3f79af57e2 --- /dev/null +++ b/lib/generators/react_on_rails/js_dependency_manager.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require_relative "generator_messages" + +# rubocop:disable Metrics/ModuleLength +module ReactOnRails + module Generators + # Shared module for managing JavaScript dependencies across generators + # This module provides common functionality for adding and installing + # JS dependencies to avoid code duplication between generators. + # + # Since react_on_rails requires shakapacker, and shakapacker includes + # package_json as a dependency, the package_json gem is always available. + # + # == Required Methods + # Including classes must include GeneratorHelper module which provides: + # - add_npm_dependencies(packages, dev: false): Add packages via package_json gem + # - package_json: Access to PackageJson instance (always available via shakapacker) + # - destination_root: Generator destination directory + # + # == Optional Methods + # Including classes may define: + # - options.rspack?: Returns true if --rspack flag is set (for Rspack support) + # - options.typescript?: Returns true if --typescript flag is set (for TypeScript support) + # + # == Installation Behavior + # The module ALWAYS runs package manager install after adding dependencies. + # This is safe because package_json gem's install method is idempotent - it only + # installs what's actually needed from package.json. This prevents edge cases + # where package.json was modified but dependencies weren't installed. + # + # == Error Handling Philosophy + # All dependency addition methods use a graceful degradation approach: + # - Methods return false on failure instead of raising exceptions + # - StandardError is caught at the lowest level (add_package) and higher levels (add_*_dependencies) + # - Failures trigger user-facing warnings via GeneratorMessages + # - Warnings provide clear manual installation instructions + # + # This ensures the generator ALWAYS completes successfully, even when: + # - Network connectivity issues prevent package downloads + # - Package manager (npm/yarn/pnpm) has permission errors + # - package_json gem encounters unexpected states + # + # Users can manually run package installation commands after generator completion. + # This is preferable to generator crashes that leave Rails apps in incomplete states. + # + # == Usage + # Include this module in generator classes and call setup_js_dependencies + # to handle all JS dependency installation via package_json gem. + module JsDependencyManager + # Core React dependencies required for React on Rails + # Note: @babel/preset-react and babel plugins are NOT included here because: + # - Shakapacker handles JavaScript transpiler configuration (babel, swc, or esbuild) + # - Users configure their preferred transpiler via shakapacker.yml javascript_transpiler setting + # - SWC is now the default and doesn't need Babel presets + # - For Babel users, shakapacker will install babel-loader and its dependencies + REACT_DEPENDENCIES = %w[ + react + react-dom + prop-types + ].freeze + + # CSS processing dependencies for webpack + CSS_DEPENDENCIES = %w[ + css-loader + css-minimizer-webpack-plugin + mini-css-extract-plugin + style-loader + ].freeze + + # Development-only dependencies for hot reloading (Webpack) + DEV_DEPENDENCIES = %w[ + @pmmmwh/react-refresh-webpack-plugin + react-refresh + ].freeze + + # Rspack core dependencies (only installed when --rspack flag is used) + RSPACK_DEPENDENCIES = %w[ + @rspack/core + rspack-manifest-plugin + ].freeze + + # Rspack development dependencies for hot reloading + RSPACK_DEV_DEPENDENCIES = %w[ + @rspack/cli + @rspack/plugin-react-refresh + react-refresh + ].freeze + + # TypeScript dependencies (only installed when --typescript flag is used) + # Note: @babel/preset-typescript is NOT included because: + # - SWC is now the default javascript_transpiler (has built-in TypeScript support) + # - Shakapacker handles the transpiler configuration via shakapacker.yml + # - If users choose javascript_transpiler: 'babel', they should manually add @babel/preset-typescript + # and configure it in their babel.config.js + TYPESCRIPT_DEPENDENCIES = %w[ + typescript + @types/react + @types/react-dom + ].freeze + + private + + def setup_js_dependencies + add_js_dependencies + + # Always run install to ensure all dependencies are properly installed. + # The package_json gem's install method is idempotent and safe to call + # even if packages were already added - it will only install what's needed. + # This ensures edge cases where package.json was modified but install wasn't + # run are handled correctly. + install_js_dependencies + end + + def add_js_dependencies + add_react_on_rails_package + add_react_dependencies + add_css_dependencies + # Rspack dependencies are only added when --rspack flag is used + add_rspack_dependencies if respond_to?(:options) && options&.rspack? + # Dev dependencies vary based on bundler choice + add_dev_dependencies + end + + def add_react_on_rails_package + # Use exact version match between gem and npm package for stable releases + # For pre-release versions (e.g., 16.1.0-rc.1), use latest to avoid installing + # a version that may not exist in the npm registry + major_minor_patch_only = /\A\d+\.\d+\.\d+\z/ + react_on_rails_pkg = if ReactOnRails::VERSION.match?(major_minor_patch_only) + "react-on-rails@#{ReactOnRails::VERSION}" + else + puts "Adding the latest react-on-rails NPM module. " \ + "Double check this is correct in package.json" + "react-on-rails" + end + + puts "Installing React on Rails package..." + return if add_package(react_on_rails_pkg) + + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Failed to add react-on-rails package. + + You can install it manually by running: + npm install #{react_on_rails_pkg} + MSG + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Error adding react-on-rails package: #{e.message} + + You can install it manually by running: + npm install #{react_on_rails_pkg} + MSG + end + + def add_react_dependencies + puts "Installing React dependencies..." + return if add_packages(REACT_DEPENDENCIES) + + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Failed to add React dependencies. + + You can install them manually by running: + npm install #{REACT_DEPENDENCIES.join(' ')} + MSG + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Error adding React dependencies: #{e.message} + + You can install them manually by running: + npm install #{REACT_DEPENDENCIES.join(' ')} + MSG + end + + def add_css_dependencies + puts "Installing CSS handling dependencies..." + return if add_packages(CSS_DEPENDENCIES) + + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Failed to add CSS dependencies. + + You can install them manually by running: + npm install #{CSS_DEPENDENCIES.join(' ')} + MSG + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Error adding CSS dependencies: #{e.message} + + You can install them manually by running: + npm install #{CSS_DEPENDENCIES.join(' ')} + MSG + end + + def add_rspack_dependencies + puts "Installing Rspack core dependencies..." + return if add_packages(RSPACK_DEPENDENCIES) + + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Failed to add Rspack dependencies. + + You can install them manually by running: + npm install #{RSPACK_DEPENDENCIES.join(' ')} + MSG + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Error adding Rspack dependencies: #{e.message} + + You can install them manually by running: + npm install #{RSPACK_DEPENDENCIES.join(' ')} + MSG + end + + def add_typescript_dependencies + puts "Installing TypeScript dependencies..." + return if add_packages(TYPESCRIPT_DEPENDENCIES, dev: true) + + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Failed to add TypeScript dependencies. + + You can install them manually by running: + npm install --save-dev #{TYPESCRIPT_DEPENDENCIES.join(' ')} + MSG + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Error adding TypeScript dependencies: #{e.message} + + You can install them manually by running: + npm install --save-dev #{TYPESCRIPT_DEPENDENCIES.join(' ')} + MSG + end + + def add_dev_dependencies + puts "Installing development dependencies..." + + # Use Rspack-specific dev dependencies if --rspack flag is set + dev_deps = if respond_to?(:options) && options&.rspack? + RSPACK_DEV_DEPENDENCIES + else + DEV_DEPENDENCIES + end + + return if add_packages(dev_deps, dev: true) + + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Failed to add development dependencies. + + You can install them manually by running: + npm install --save-dev #{dev_deps.join(' ')} + MSG + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ Error adding development dependencies: #{e.message} + + You can install them manually by running: + npm install --save-dev #{dev_deps.join(' ')} + MSG + end + + # Add a single dependency using package_json gem + # + # This method is used internally for adding the react-on-rails package + # with version-specific handling (react-on-rails@VERSION). + # For batch operations, use add_packages instead. + # + # The exact: true flag ensures version pinning aligns with the gem version, + # preventing version mismatches between the Ruby gem and NPM package. + # + # @param package [String] Package specifier (e.g., "react-on-rails@16.0.0") + # @param dev [Boolean] Whether to add as dev dependency + # @return [Boolean] true if successful, false otherwise + def add_package(package, dev: false) + pj = package_json + return false unless pj + + begin + # Ensure package is in array format for package_json gem + packages_array = [package] + if dev + pj.manager.add(packages_array, type: :dev, exact: true) + else + pj.manager.add(packages_array, exact: true) + end + true + rescue StandardError + # Return false to trigger warning in calling method + false + end + end + + # Add multiple dependencies at once using package_json gem + # + # This method delegates to GeneratorHelper's add_npm_dependencies for + # better package manager abstraction and batch processing efficiency. + # + # @param packages [Array] Package names to add + # @param dev [Boolean] Whether to add as dev dependencies + # @return [Boolean] true if successful, false otherwise + def add_packages(packages, dev: false) + # Use the add_npm_dependencies helper from GeneratorHelper + add_npm_dependencies(packages, dev: dev) + end + + def install_js_dependencies + # Use package_json gem's install method (always available via shakapacker) + # package_json is guaranteed to be available because: + # 1. react_on_rails gemspec requires shakapacker + # 2. shakapacker gemspec requires package_json + # 3. GeneratorHelper provides package_json method + pj = package_json + unless pj + GeneratorMessages.add_warning("package_json not available, skipping dependency installation") + return false + end + + pj.manager.install + true + rescue StandardError => e + GeneratorMessages.add_warning(<<~MSG.strip) + ⚠️ JavaScript dependencies installation failed: #{e.message} + + This could be due to network issues or package manager problems. + You can install dependencies manually later by running: + • npm install (if using npm) + • yarn install (if using yarn) + • pnpm install (if using pnpm) + MSG + false + end + end + end +end +# rubocop:enable Metrics/ModuleLength 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 index 30e290fcbc..26f2db0dbf 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 @@ -36,8 +36,15 @@ default: &default # Reload manifest.json on all requests so we reload latest compiled packs cache_manifest: false - # Select loader to use, available options are 'babel' (default), 'swc' or 'esbuild' - webpack_loader: 'babel' + # Select JavaScript transpiler to use + # Available options: 'swc' (default, 20x faster), 'babel', 'esbuild', or 'none' + # Use 'none' when providing a completely custom webpack configuration + # Note: When using rspack, swc is used automatically regardless of this setting + javascript_transpiler: "swc" + + # Select assets bundler to use + # Available options: 'webpack' (default) or 'rspack' + assets_bundler: "webpack" # Raises an error if there is a mismatch in the shakapacker gem and npm package being used ensure_consistent_versioning: true diff --git a/sig/react_on_rails/generators/js_dependency_manager.rbs b/sig/react_on_rails/generators/js_dependency_manager.rbs new file mode 100644 index 0000000000..5be608e2b9 --- /dev/null +++ b/sig/react_on_rails/generators/js_dependency_manager.rbs @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module ReactOnRails + module Generators + # Type signatures for JsDependencyManager module + # + # This module provides common functionality for managing JavaScript dependencies + # in Rails generators using the package_json gem (available via shakapacker). + module JsDependencyManager + # Core React dependencies required for React on Rails + REACT_DEPENDENCIES: Array[String] + + # CSS processing dependencies for webpack + CSS_DEPENDENCIES: Array[String] + + # Development-only dependencies for hot reloading (Webpack) + DEV_DEPENDENCIES: Array[String] + + # Rspack core dependencies (only installed when --rspack flag is used) + RSPACK_DEPENDENCIES: Array[String] + + # Rspack development dependencies for hot reloading + RSPACK_DEV_DEPENDENCIES: Array[String] + + # TypeScript dependencies (only installed when --typescript flag is used) + TYPESCRIPT_DEPENDENCIES: Array[String] + + private + + # Sets up JavaScript dependencies by adding and installing packages + # + # This method orchestrates the entire dependency setup process: + # 1. Adds all required packages to package.json + # 2. Runs package manager install + # + # @return [void] + def setup_js_dependencies: () -> void + + # Adds all JavaScript dependencies to package.json + # + # This method calls individual add_*_dependencies methods in sequence. + # All errors are handled gracefully with warnings rather than exceptions. + # + # @return [void] + def add_js_dependencies: () -> void + + # Adds the react-on-rails package to package.json + # + # Uses version matching for stable releases, or latest for pre-releases. + # Adds error message to GeneratorMessages if package addition fails. + # + # @return [void] + def add_react_on_rails_package: () -> void + + # Adds React dependencies to package.json + # + # Adds error message to GeneratorMessages if package addition fails. + # + # @return [void] + def add_react_dependencies: () -> void + + # Adds CSS processing dependencies to package.json + # + # Adds error message to GeneratorMessages if package addition fails. + # + # @return [void] + def add_css_dependencies: () -> void + + # Adds Rspack dependencies to package.json + # + # Only called when --rspack flag is set. + # Adds error message to GeneratorMessages if package addition fails. + # + # @return [void] + def add_rspack_dependencies: () -> void + + # Adds TypeScript dependencies to package.json as dev dependencies + # + # Only called when --typescript flag is set. + # Adds error message to GeneratorMessages if package addition fails. + # + # @return [void] + def add_typescript_dependencies: () -> void + + # Adds development dependencies to package.json + # + # Chooses between Webpack or Rspack dev dependencies based on --rspack flag. + # Adds error message to GeneratorMessages if package addition fails. + # + # @return [void] + def add_dev_dependencies: () -> void + + # Adds a single package using package_json gem + # + # This method is used internally for adding the react-on-rails package + # with version-specific handling (react-on-rails@VERSION). + # For batch operations, use add_packages instead. + # + # @param package [String] Package specifier (e.g., "react-on-rails@16.0.0") + # @param dev [bool] Whether to add as dev dependency + # @return [bool] true if successful, false otherwise + def add_package: (String package, ?dev: bool) -> bool + + # Adds multiple packages at once using package_json gem + # + # Delegates to GeneratorHelper's add_npm_dependencies for better + # package manager abstraction and batch processing efficiency. + # + # @param packages [Array] Package names to add + # @param dev [bool] Whether to add as dev dependencies + # @return [bool] true if successful, false otherwise + def add_packages: (Array[String] packages, ?dev: bool) -> bool + + # Installs JavaScript dependencies using package_json gem + # + # Always available via shakapacker dependency chain. + # Adds warning to GeneratorMessages if installation fails. + # + # @return [bool] true if successful, false otherwise + def install_js_dependencies: () -> bool + end + end +end diff --git a/spec/react_on_rails/generators/js_dependency_manager_spec.rb b/spec/react_on_rails/generators/js_dependency_manager_spec.rb new file mode 100644 index 0000000000..1fe676d7f1 --- /dev/null +++ b/spec/react_on_rails/generators/js_dependency_manager_spec.rb @@ -0,0 +1,387 @@ +# frozen_string_literal: true + +require_relative "../support/generator_spec_helper" + +describe ReactOnRails::Generators::JsDependencyManager, type: :generator do + # Create a test class that includes the module for testing + let(:test_class) do + Class.new do + include ReactOnRails::Generators::JsDependencyManager + + attr_accessor :options + + # Mock methods required by JsDependencyManager + def add_npm_dependencies(_packages, dev: false) + @add_npm_dependencies_called = true + @add_npm_dependencies_dev = dev + @add_npm_dependencies_result + end + + attr_reader :package_json + + def destination_root + "/test/path" + end + + # Test helpers + attr_writer :add_npm_dependencies_result + + def add_npm_dependencies_called? + @add_npm_dependencies_called + end + + def add_npm_dependencies_dev? + @add_npm_dependencies_dev + end + + attr_writer :package_json + end + end + + let(:instance) { test_class.new } + # rubocop:disable RSpec/VerifiedDoubles + let(:mock_manager) { double("PackageManager", install: true, add: true) } + let(:mock_package_json) { double("PackageJson", manager: mock_manager) } + # rubocop:enable RSpec/VerifiedDoubles + + # Helper methods to filter GeneratorMessages output + def warnings + GeneratorMessages.output.select { |msg| msg.to_s.include?("WARNING") } + end + + def errors + GeneratorMessages.output.select { |msg| msg.to_s.include?("ERROR") } + end + + before do + # Clear any previous messages + GeneratorMessages.clear + # Set up default mocks + instance.package_json = mock_package_json + instance.add_npm_dependencies_result = true + end + + describe "constants" do + it "defines REACT_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::REACT_DEPENDENCIES).to eq(%w[ + react + react-dom + prop-types + ]) + end + + it "defines CSS_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::CSS_DEPENDENCIES).to eq(%w[ + css-loader + css-minimizer-webpack-plugin + mini-css-extract-plugin + style-loader + ]) + end + + it "defines DEV_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::DEV_DEPENDENCIES).to( + eq(%w[@pmmmwh/react-refresh-webpack-plugin react-refresh]) + ) + end + + it "defines RSPACK_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::RSPACK_DEPENDENCIES).to eq(%w[ + @rspack/core + rspack-manifest-plugin + ]) + end + + it "defines RSPACK_DEV_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::RSPACK_DEV_DEPENDENCIES).to( + eq(%w[@rspack/cli @rspack/plugin-react-refresh react-refresh]) + ) + end + + it "defines TYPESCRIPT_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::TYPESCRIPT_DEPENDENCIES).to eq(%w[ + typescript + @types/react + @types/react-dom + ]) + end + + it "does not include Babel presets in REACT_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::REACT_DEPENDENCIES).not_to include( + "@babel/preset-react" + ) + end + + it "does not include Babel TypeScript preset in TYPESCRIPT_DEPENDENCIES" do + expect(ReactOnRails::Generators::JsDependencyManager::TYPESCRIPT_DEPENDENCIES).not_to include( + "@babel/preset-typescript" + ) + end + end + + describe "#add_packages" do + it "delegates to add_npm_dependencies" do + result = instance.send(:add_packages, %w[package1 package2]) + expect(result).to be(true) + expect(instance.add_npm_dependencies_called?).to be(true) + end + + it "passes dev flag to add_npm_dependencies" do + instance.send(:add_packages, %w[package1], dev: true) + expect(instance.add_npm_dependencies_dev?).to be(true) + end + + it "returns false when add_npm_dependencies fails" do + instance.add_npm_dependencies_result = false + result = instance.send(:add_packages, %w[package1]) + expect(result).to be(false) + end + end + + describe "#add_package" do + it "adds a single package successfully" do + result = instance.send(:add_package, "react-on-rails@16.0.0") + expect(result).to be(true) + expect(mock_manager).to have_received(:add).with(["react-on-rails@16.0.0"], exact: true) + end + + it "adds a dev dependency when dev: true" do + result = instance.send(:add_package, "typescript", dev: true) + expect(result).to be(true) + expect(mock_manager).to have_received(:add).with(["typescript"], type: :dev, exact: true) + end + + it "uses exact: true flag for consistency with add_npm_dependencies" do + instance.send(:add_package, "some-package") + expect(mock_manager).to have_received(:add).with(["some-package"], exact: true) + end + + it "returns false when package_json is nil" do + instance.package_json = nil + result = instance.send(:add_package, "some-package") + expect(result).to be(false) + end + + it "returns false and logs warning when add raises error" do + allow(mock_manager).to receive(:add).and_raise(StandardError, "Network error") + result = instance.send(:add_package, "some-package") + expect(result).to be(false) + end + end + + describe "#install_js_dependencies" do + it "calls package_json.manager.install" do + result = instance.send(:install_js_dependencies) + expect(result).to be(true) + expect(mock_manager).to have_received(:install) + end + + it "returns false and adds warning when package_json is nil" do + instance.package_json = nil + + result = instance.send(:install_js_dependencies) + + expect(result).to be(false) + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("package_json not available") + end + + it "returns false and adds warning when install fails" do + allow(mock_manager).to receive(:install).and_raise(StandardError, "Network timeout") + + result = instance.send(:install_js_dependencies) + + expect(result).to be(false) + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("JavaScript dependencies installation failed") + end + end + + describe "#add_react_on_rails_package" do + before do + # Stub VERSION constant + stub_const("ReactOnRails::VERSION", "16.0.0") + end + + it "adds react-on-rails with version for stable releases" do + instance.send(:add_react_on_rails_package) + expect(mock_manager).to have_received(:add).with(["react-on-rails@16.0.0"], exact: true) + end + + it "adds react-on-rails without version for pre-releases" do + stub_const("ReactOnRails::VERSION", "16.0.0-rc.1") + instance.send(:add_react_on_rails_package) + expect(mock_manager).to have_received(:add).with(["react-on-rails"], exact: true) + end + + it "adds warning when add_package fails" do + allow(mock_manager).to receive(:add).and_return(false) + instance.package_json = nil + + instance.send(:add_react_on_rails_package) + + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("Failed to add react-on-rails package") + end + + it "catches exceptions in add_package and adds warning" do + allow(mock_manager).to receive(:add).and_raise(StandardError, "Connection refused") + + instance.send(:add_react_on_rails_package) + + expect(warnings.size).to be > 0 + # When add_package catches exception, it returns false, triggering the "Failed to add" warning + expect(warnings.first.to_s).to include("Failed to add react-on-rails package") + end + end + + describe "#add_react_dependencies" do + it "adds React dependencies successfully" do + instance.send(:add_react_dependencies) + expect(instance.add_npm_dependencies_called?).to be(true) + end + + it "adds warning when add_packages fails" do + instance.add_npm_dependencies_result = false + + instance.send(:add_react_dependencies) + + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("Failed to add React dependencies") + end + end + + describe "#add_css_dependencies" do + it "adds CSS dependencies successfully" do + instance.send(:add_css_dependencies) + expect(instance.add_npm_dependencies_called?).to be(true) + end + + it "adds warning when add_packages fails" do + instance.add_npm_dependencies_result = false + + instance.send(:add_css_dependencies) + + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("Failed to add CSS dependencies") + end + end + + describe "#add_dev_dependencies" do + it "adds Webpack dev dependencies by default" do + instance.send(:add_dev_dependencies) + expect(instance.add_npm_dependencies_called?).to be(true) + expect(instance.add_npm_dependencies_dev?).to be(true) + end + + it "adds Rspack dev dependencies when --rspack flag is set" do + # rubocop:disable RSpec/VerifiedDoubles + options = double("Options", rspack?: true) + # rubocop:enable RSpec/VerifiedDoubles + instance.options = options + + instance.send(:add_dev_dependencies) + + expect(instance.add_npm_dependencies_called?).to be(true) + expect(instance.add_npm_dependencies_dev?).to be(true) + end + + it "adds warning when add_packages fails" do + instance.add_npm_dependencies_result = false + + instance.send(:add_dev_dependencies) + + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("Failed to add development dependencies") + end + end + + describe "#add_rspack_dependencies" do + it "adds Rspack dependencies successfully" do + instance.send(:add_rspack_dependencies) + expect(instance.add_npm_dependencies_called?).to be(true) + end + + it "adds warning when add_packages fails" do + instance.add_npm_dependencies_result = false + + instance.send(:add_rspack_dependencies) + + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("Failed to add Rspack dependencies") + end + end + + describe "#add_typescript_dependencies" do + it "adds TypeScript dependencies as dev dependencies" do + instance.send(:add_typescript_dependencies) + expect(instance.add_npm_dependencies_called?).to be(true) + expect(instance.add_npm_dependencies_dev?).to be(true) + end + + it "adds warning when add_packages fails" do + instance.add_npm_dependencies_result = false + + instance.send(:add_typescript_dependencies) + + expect(warnings.size).to be > 0 + expect(warnings.first.to_s).to include("Failed to add TypeScript dependencies") + end + end + + describe "error handling consistency" do + it "all add_* methods use warnings instead of errors" do + instance.add_npm_dependencies_result = false + instance.package_json = nil + + # Call all add methods + instance.send(:add_react_on_rails_package) + instance.send(:add_react_dependencies) + instance.send(:add_css_dependencies) + instance.send(:add_rspack_dependencies) + instance.send(:add_typescript_dependencies) + instance.send(:add_dev_dependencies) + + # All should add warnings, not errors + expect(warnings.count).to be >= 6 + expect(errors.size).to eq(0) + end + + it "all warning messages include manual installation instructions" do + instance.add_npm_dependencies_result = false + + instance.send(:add_react_dependencies) + + warning = warnings.first + expect(warning.to_s).to include("npm install") + expect(warning.to_s).to include("manually") + end + end + + describe "graceful degradation" do + it "setup_js_dependencies completes successfully even when all package operations fail" do + # Simulate complete package installation failure + instance.add_npm_dependencies_result = false + instance.package_json = nil + + # This should not raise any exceptions + expect { instance.send(:setup_js_dependencies) }.not_to raise_error + + # Should have generated warnings for failures + expect(warnings.size).to be > 0 + # But no errors that would crash the generator + expect(errors.size).to eq(0) + end + + it "setup_js_dependencies completes when install fails but add succeeds" do + instance.add_npm_dependencies_result = true + allow(mock_manager).to receive(:install).and_raise(StandardError, "Network timeout") + + # Should not raise despite install failure + expect { instance.send(:setup_js_dependencies) }.not_to raise_error + + # Should have warning about install failure + expect(warnings.any? { |w| w.to_s.include?("installation failed") }).to be(true) + end + end +end diff --git a/spec/react_on_rails/generators/message_deduplication_spec.rb b/spec/react_on_rails/generators/message_deduplication_spec.rb new file mode 100644 index 0000000000..ff552849aa --- /dev/null +++ b/spec/react_on_rails/generators/message_deduplication_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require_relative "../support/generator_spec_helper" +require_relative "../support/version_test_helpers" + +describe "Message Deduplication", type: :generator do + include GeneratorSpec::TestCase + + destination File.expand_path("../dummy-for-generators", __dir__) + tests ReactOnRails::Generators::InstallGenerator + + describe "Post-install message handling" do + before do + # Clear any previous messages to ensure clean test state + GeneratorMessages.clear + # Mock Shakapacker installation to succeed + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with("bin/shakapacker").and_return(true) + allow(File).to receive(:exist?).with("bin/shakapacker-dev-server").and_return(true) + allow(File).to receive(:exist?).with("config/shakapacker.yml").and_return(true) + allow(File).to receive(:exist?).with("config/webpack/webpack.config.js").and_return(true) + # Mock file reading for webpack config - use call_original first, then specific mock + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with("config/webpack/webpack.config.js").and_return("// mock webpack config") + end + + context "with non-Redux installation" do + it "shows the success message exactly once" do + run_generator_test_with_args(%w[], package_json: true) + output_text = GeneratorMessages.output.join("\n") + + # Count occurrences of the success message + success_count = output_text.scan("🎉 React on Rails Successfully Installed!").count + expect(success_count).to( + eq(1), + "Expected success message to appear exactly once, but appeared #{success_count} times" + ) + + # Ensure post-install message components are present + expect(output_text).to include("📋 QUICK START:") + expect(output_text).to include("✨ KEY FEATURES:") + end + end + + context "with Redux installation" do + it "shows the success message exactly once" do + run_generator_test_with_args(%w[--redux], package_json: true) + output_text = GeneratorMessages.output.join("\n") + + # Count occurrences of the success message + success_count = output_text.scan("🎉 React on Rails Successfully Installed!").count + expect(success_count).to( + eq(1), + "Expected success message to appear exactly once with Redux, but appeared #{success_count} times" + ) + + # Ensure post-install message components are present + expect(output_text).to include("📋 QUICK START:") + expect(output_text).to include("✨ KEY FEATURES:") + + # The message should be from the Redux generator, containing Redux-specific info + expect(output_text).to include("HelloWorldApp") + end + end + end + + describe "NPM install execution" do + let(:install_generator) { ReactOnRails::Generators::InstallGenerator.new } + + before do + # Mock the system to track NPM install calls + allow(install_generator).to receive_messages( + system: true, + add_npm_dependencies: false, + destination_root: "/test/path" + ) + allow(File).to receive(:exist?).and_return(false) + allow(File).to receive(:exist?).with(a_string_matching(/package\.json$/)).and_return(true) + end + + context "when using package_json gem (always available via shakapacker)" do + # rubocop:disable RSpec/VerifiedDoubles + let(:mock_manager) { double("PackageManager", install: true, add: true) } + let(:mock_package_json) { double("PackageJson", manager: mock_manager) } + # rubocop:enable RSpec/VerifiedDoubles + + before do + # Mock the actual methods used by JsDependencyManager + # add_npm_dependencies is from GeneratorHelper and is used by add_packages + # Mock package_json to prevent actual package manager calls + allow(install_generator).to receive_messages( + add_npm_dependencies: true, + package_json: mock_package_json + ) + end + + it "does not run duplicate install commands" do + # When package_json gem methods work, it should NOT call system() commands + expect(install_generator).not_to receive(:system) + + # Run the dependency setup + install_generator.send(:setup_js_dependencies) + + # Verify package_json.manager.install was called exactly once + expect(mock_manager).to have_received(:install).once + end + end + end + + describe "JS dependency method organization" do + it "uses the shared JsDependencyManager module in base_generator" do + expect(ReactOnRails::Generators::BaseGenerator.ancestors) + .to include(ReactOnRails::Generators::JsDependencyManager) + end + + it "uses the shared JsDependencyManager module in install_generator" do + expect(ReactOnRails::Generators::InstallGenerator.ancestors) + .to include(ReactOnRails::Generators::JsDependencyManager) + end + + it "does not duplicate JS dependency methods between generators" do + base_generator = ReactOnRails::Generators::BaseGenerator.new + install_generator = ReactOnRails::Generators::InstallGenerator.new + + # Both should respond to the shared methods + shared_methods = %i[setup_js_dependencies add_js_dependencies install_js_dependencies] + + shared_methods.each do |method| + expect(base_generator.respond_to?(method, true)).to be(true) + expect(install_generator.respond_to?(method, true)).to be(true) + # Verify the methods are defined by the shared module + expect(ReactOnRails::Generators::BaseGenerator.instance_method(method).owner) + .to eq(ReactOnRails::Generators::JsDependencyManager) + expect(ReactOnRails::Generators::InstallGenerator.instance_method(method).owner) + .to eq(ReactOnRails::Generators::JsDependencyManager) + end + end + end +end