diff --git a/RSPACK_IMPLEMENTATION.md b/RSPACK_IMPLEMENTATION.md new file mode 100644 index 000000000..4c4bc8412 --- /dev/null +++ b/RSPACK_IMPLEMENTATION.md @@ -0,0 +1,160 @@ +# Rspack Generator Option Implementation + +This document summarizes the implementation of the `--rspack` option for the React on Rails generator, based on the patterns from [PR #20 in react_on_rails-demos](https://github.com/shakacode/react_on_rails-demos/pull/20). + +## Overview + +The `--rspack` flag allows users to generate a React on Rails application using Rspack instead of Webpack as the bundler. Rspack provides significantly faster build times (~53-270ms vs typical webpack builds). + +## Changes Made + +### 1. Install Generator (`lib/generators/react_on_rails/install_generator.rb`) + +- **Added `--rspack` class option** (line 31-35): Boolean flag to enable Rspack bundler +- **Updated `invoke_generators`** (line 82-83): Pass rspack option to base generator +- **Added `add_rspack_dependencies` method** (line 499-513): Installs Rspack core packages: + - `@rspack/core` + - `rspack-manifest-plugin` +- **Updated `add_dev_dependencies`** (line 515-534): Conditionally installs rspack or webpack refresh plugins: + - Rspack: `@rspack/cli`, `@rspack/plugin-react-refresh`, `react-refresh` + - Webpack: `@pmmmwh/react-refresh-webpack-plugin`, `react-refresh` +- **Updated `add_js_dependencies`** (line 433): Calls `add_rspack_dependencies` when rspack flag is set + +### 2. Base Generator (`lib/generators/react_on_rails/base_generator.rb`) + +- **Added `--rspack` class option** (line 22-26): Boolean flag (passed from install generator) +- **Updated `copy_packer_config`** (line 85-100): Calls `configure_rspack_in_shakapacker` after copying config +- **Added `configure_rspack_in_shakapacker` method** (line 404-426): + - Adds `assets_bundler: 'rspack'` to shakapacker.yml default section + - Changes `webpack_loader` to `'swc'` (Rspack works best with SWC transpiler) + +### 3. Webpack Configuration Templates + +Updated webpack configuration templates to support both webpack and rspack bundlers with unified config approach: + +**development.js.tt**: + +- Added `config` to shakapacker require to access `assets_bundler` setting +- Conditional React Refresh plugin loading based on `config.assets_bundler`: + - Rspack: Uses `@rspack/plugin-react-refresh` + - Webpack: Uses `@pmmmwh/react-refresh-webpack-plugin` +- Prevents "window not found" errors when using rspack + +**serverWebpackConfig.js.tt**: + +- Added `bundler` variable that conditionally requires `@rspack/core` or `webpack` +- Changed `webpack.optimize.LimitChunkCountPlugin` to `bundler.optimize.LimitChunkCountPlugin` +- Enables same config to work with both bundlers without warnings +- Avoids hardcoding webpack-specific imports + +### 4. Bundler Switching Script (`lib/generators/react_on_rails/templates/base/base/bin/switch-bundler`) + +Created a new executable script that allows switching between webpack and rspack after installation: + +**Features:** + +- Updates `shakapacker.yml` with correct `assets_bundler` setting +- Switches `webpack_loader` between 'swc' (rspack) and 'babel' (webpack) +- Removes old bundler dependencies from package.json +- Installs new bundler dependencies +- Supports npm, yarn, and pnpm package managers +- Auto-detects package manager from lock files + +**Usage:** + +```bash +bin/switch-bundler rspack # Switch to Rspack +bin/switch-bundler webpack # Switch to Webpack +``` + +**Dependencies managed:** + +- **Webpack**: webpack, webpack-cli, webpack-dev-server, webpack-assets-manifest, webpack-merge, @pmmmwh/react-refresh-webpack-plugin +- **Rspack**: @rspack/core, @rspack/cli, @rspack/plugin-react-refresh, rspack-manifest-plugin + +## Usage + +### Generate new app with Rspack: + +```bash +rails generate react_on_rails:install --rspack +``` + +### Generate with Rspack and TypeScript: + +```bash +rails generate react_on_rails:install --rspack --typescript +``` + +### Generate with Rspack and Redux: + +```bash +rails generate react_on_rails:install --rspack --redux +``` + +### Switch existing app to Rspack: + +```bash +bin/switch-bundler rspack +``` + +## Configuration Changes + +When `--rspack` is used, the following configuration changes are applied to `config/shakapacker.yml`: + +```yaml +default: &default + source_path: app/javascript + assets_bundler: 'rspack' # Added + # ... other settings + webpack_loader: 'swc' # Changed from 'babel' +``` + +## Dependencies + +### Rspack-specific packages installed: + +**Production:** + +- `@rspack/core` - Core Rspack bundler +- `rspack-manifest-plugin` - Manifest generation for Rspack + +**Development:** + +- `@rspack/cli` - Rspack CLI tools +- `@rspack/plugin-react-refresh` - React Fast Refresh for Rspack +- `react-refresh` - React Fast Refresh runtime + +### Webpack packages NOT installed with --rspack: + +**Production:** + +- `webpack` +- `webpack-assets-manifest` +- `webpack-merge` + +**Development:** + +- `webpack-cli` +- `webpack-dev-server` +- `@pmmmwh/react-refresh-webpack-plugin` + +## Performance Benefits + +According to PR #20: + +- Build times: ~53-270ms with Rspack vs typical webpack builds +- Approximately 20x faster transpilation with SWC (used by Rspack) +- Faster development builds and CI runs + +## Testing + +The implementation follows existing generator patterns and passes RuboCop checks with zero offenses. + +## Compatibility + +- Works with existing webpack configuration files (unified config approach) +- Compatible with TypeScript option (`--typescript`) +- Compatible with Redux option (`--redux`) +- Supports all package managers (npm, yarn, pnpm) +- Reversible via `bin/switch-bundler` script diff --git a/lib/generators/react_on_rails/base_generator.rb b/lib/generators/react_on_rails/base_generator.rb index 6f8746a08..8db8005ea 100644 --- a/lib/generators/react_on_rails/base_generator.rb +++ b/lib/generators/react_on_rails/base_generator.rb @@ -19,6 +19,12 @@ class BaseGenerator < Rails::Generators::Base desc: "Install Redux package and Redux version of Hello World Example", aliases: "-R" + # --rspack + class_option :rspack, + type: :boolean, + default: false, + desc: "Use Rspack instead of Webpack as the bundler" + def add_hello_world_route route "get 'hello_world', to: 'hello_world#index'" end @@ -82,6 +88,7 @@ def copy_packer_config if File.exist?(".shakapacker_just_installed") puts "Skipping Shakapacker config copy (already installed by Shakapacker installer)" File.delete(".shakapacker_just_installed") # Clean up marker + configure_rspack_in_shakapacker if options.rspack? return end @@ -89,6 +96,7 @@ def copy_packer_config base_path = "base/base/" config = "config/shakapacker.yml" copy_file("#{base_path}#{config}", config) + configure_rspack_in_shakapacker if options.rspack? end def add_base_gems_to_gemfile @@ -392,6 +400,30 @@ def add_configure_rspec_to_compile_assets(helper_file) search_str = "RSpec.configure do |config|" gsub_file(helper_file, search_str, CONFIGURE_RSPEC_TO_COMPILE_ASSETS) end + + def configure_rspack_in_shakapacker + shakapacker_config_path = "config/shakapacker.yml" + return unless File.exist?(shakapacker_config_path) + + puts Rainbow("🔧 Configuring Shakapacker for Rspack...").yellow + + # Read the current config + config_content = File.read(shakapacker_config_path) + + # Update assets_bundler to rspack in default section + unless config_content.include?("assets_bundler:") + # Add assets_bundler after source_path in default section + config_content.gsub!(/^default: &default\n(\s+source_path:.*\n)/) do + "default: &default\n#{Regexp.last_match(1)} assets_bundler: 'rspack'\n" + end + end + + # Update webpack_loader to swc (rspack works best with SWC) + config_content.gsub!(/^\s*webpack_loader:.*$/, " webpack_loader: 'swc'") + + File.write(shakapacker_config_path, config_content) + puts Rainbow("✅ Updated shakapacker.yml for Rspack").green + end end end end diff --git a/lib/generators/react_on_rails/install_generator.rb b/lib/generators/react_on_rails/install_generator.rb index b5769628d..eb0d4d4e3 100644 --- a/lib/generators/react_on_rails/install_generator.rb +++ b/lib/generators/react_on_rails/install_generator.rb @@ -28,6 +28,12 @@ class InstallGenerator < Rails::Generators::Base desc: "Generate TypeScript files and install TypeScript dependencies. Default: false", aliases: "-T" + # --rspack + class_option :rspack, + type: :boolean, + default: false, + desc: "Use Rspack instead of Webpack as the bundler. Default: false" + # --ignore-warnings class_option :ignore_warnings, type: :boolean, @@ -73,7 +79,8 @@ def invoke_generators create_css_module_types create_typescript_config end - invoke "react_on_rails:base", [], { typescript: options.typescript?, redux: options.redux? } + invoke "react_on_rails:base", [], + { typescript: options.typescript?, redux: options.redux?, rspack: options.rspack? } if options.redux? invoke "react_on_rails:react_with_redux", [], { typescript: options.typescript? } else @@ -424,6 +431,7 @@ 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 @@ -489,12 +497,36 @@ def add_css_dependencies 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 = %w[ - @pmmmwh/react-refresh-webpack-plugin - react-refresh - ] + 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 diff --git a/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler b/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler new file mode 100755 index 000000000..1ead3e3d0 --- /dev/null +++ b/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler @@ -0,0 +1,145 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "yaml" +require "json" + +# Script to switch between webpack and rspack bundlers +class BundlerSwitcher + WEBPACK_DEPS = { + dependencies: %w[webpack webpack-assets-manifest webpack-merge], + dev_dependencies: %w[webpack-cli webpack-dev-server @pmmmwh/react-refresh-webpack-plugin] + }.freeze + + RSPACK_DEPS = { + dependencies: %w[@rspack/core rspack-manifest-plugin], + dev_dependencies: %w[@rspack/cli @rspack/plugin-react-refresh] + }.freeze + + def initialize(target_bundler) + @target_bundler = target_bundler.to_s.downcase + @shakapacker_config = "config/shakapacker.yml" + validate_bundler! + end + + def switch! + puts "🔄 Switching to #{@target_bundler}..." + + update_shakapacker_config + update_dependencies + install_dependencies + + puts "✅ Successfully switched to #{@target_bundler}!" + puts "\nNext steps:" + puts " 1. Review your webpack configuration files in config/webpack/" + puts " 2. Restart your development server" + end + + private + + def validate_bundler! + return if %w[webpack rspack].include?(@target_bundler) + + abort "❌ Invalid bundler: #{@target_bundler}. Use 'webpack' or 'rspack'" + end + + def update_shakapacker_config + abort "❌ #{@shakapacker_config} not found" unless File.exist?(@shakapacker_config) + + puts "📝 Updating #{@shakapacker_config}..." + config = YAML.load_file(@shakapacker_config) + + config["default"] ||= {} + config["default"]["assets_bundler"] = @target_bundler + + # Update webpack_loader based on bundler + # Rspack works best with SWC, webpack typically uses babel + config["default"]["webpack_loader"] = @target_bundler == "rspack" ? "swc" : "babel" + + File.write(@shakapacker_config, YAML.dump(config)) + puts "✅ Updated assets_bundler to '#{@target_bundler}'" + end + + # rubocop:disable Metrics/CyclomaticComplexity + def update_dependencies + puts "📦 Updating package.json dependencies..." + + package_json_path = "package.json" + unless File.exist?(package_json_path) + puts "⚠️ package.json not found, skipping dependency updates" + return + end + + package_json = JSON.parse(File.read(package_json_path)) + + remove_deps = @target_bundler == "rspack" ? WEBPACK_DEPS : RSPACK_DEPS + + # Remove old bundler dependencies + remove_deps[:dependencies].each do |dep| + package_json["dependencies"]&.delete(dep) + end + remove_deps[:dev_dependencies].each do |dep| + package_json["devDependencies"]&.delete(dep) + end + + puts "✅ Removed #{@target_bundler == 'rspack' ? 'webpack' : 'rspack'} dependencies" + File.write(package_json_path, JSON.pretty_generate(package_json)) + end + # rubocop:enable Metrics/CyclomaticComplexity + + # rubocop:disable Metrics/CyclomaticComplexity + def install_dependencies + puts "📥 Installing #{@target_bundler} dependencies..." + + deps = @target_bundler == "rspack" ? RSPACK_DEPS : WEBPACK_DEPS + + # Detect package manager + package_manager = detect_package_manager + + # Install dependencies using array form to prevent command injection + success = case package_manager + when "yarn" + system("yarn", "add", *deps[:dependencies]) + when "pnpm" + system("pnpm", "add", *deps[:dependencies]) + else + system("npm", "install", *deps[:dependencies]) + end + + abort("❌ Failed to install dependencies") unless success + + # Install dev dependencies using array form to prevent command injection + success = case package_manager + when "yarn" + system("yarn", "add", "-D", *deps[:dev_dependencies]) + when "pnpm" + system("pnpm", "add", "-D", *deps[:dev_dependencies]) + else + system("npm", "install", "--save-dev", *deps[:dev_dependencies]) + end + + abort("❌ Failed to install dev dependencies") unless success + + puts "✅ Installed #{@target_bundler} dependencies" + end + # rubocop:enable Metrics/CyclomaticComplexity + + def detect_package_manager + return "yarn" if File.exist?("yarn.lock") + return "pnpm" if File.exist?("pnpm-lock.yaml") + + "npm" + end +end + +# Main execution +if ARGV.empty? + puts "Usage: bin/switch-bundler [webpack|rspack]" + puts "\nExamples:" + puts " bin/switch-bundler rspack # Switch to Rspack" + puts " bin/switch-bundler webpack # Switch to Webpack" + exit 1 +end + +BundlerSwitcher.new(ARGV[0]).switch! diff --git a/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt b/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt index e3c26a231..ce783668d 100644 --- a/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +++ b/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt @@ -1,20 +1,26 @@ <%= add_documentation_reference(config[:message], "// https://github.com/shakacode/react_on_rails_demo_ssr_hmr/blob/master/config/webpack/development.js") %> -const { devServer, inliningCss } = require('shakapacker'); +const { devServer, inliningCss, config } = require('shakapacker'); const generateWebpackConfigs = require('./generateWebpackConfigs'); const developmentEnvOnly = (clientWebpackConfig, _serverWebpackConfig) => { - // React Refresh (Fast Refresh) setup - only when webpack-dev-server is running (HMR mode) - // This matches the condition in generateWebpackConfigs.js and babel.config.js + // React Refresh (Fast Refresh) setup - only when dev server is running (HMR mode) if (process.env.WEBPACK_SERVE) { // eslint-disable-next-line global-require - const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); - clientWebpackConfig.plugins.push( - new ReactRefreshWebpackPlugin({ - // Use default overlay configuration for better compatibility - }), - ); + if (config.assets_bundler === 'rspack') { + // Rspack uses @rspack/plugin-react-refresh for React Fast Refresh + const ReactRefreshPlugin = require('@rspack/plugin-react-refresh'); + clientWebpackConfig.plugins.push(new ReactRefreshPlugin()); + } else { + // Webpack uses @pmmmwh/react-refresh-webpack-plugin + const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); + clientWebpackConfig.plugins.push( + new ReactRefreshWebpackPlugin({ + // Use default overlay configuration for better compatibility + }), + ); + } } }; 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 deb724704..ec6e52791 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 @@ -3,7 +3,9 @@ const { merge, config } = require('shakapacker'); const commonWebpackConfig = require('./commonWebpackConfig'); -const webpack = require('webpack'); +const bundler = config.assets_bundler === 'rspack' + ? require('@rspack/core') + : require('webpack'); const configureServer = () => { // We need to use "merge" because the clientConfigObject, EVEN after running @@ -40,7 +42,7 @@ const configureServer = () => { serverWebpackConfig.optimization = { minimize: false, }; - serverWebpackConfig.plugins.unshift(new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })); + 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 diff --git a/spec/react_on_rails/generators/install_generator_spec.rb b/spec/react_on_rails/generators/install_generator_spec.rb index ca095cf8f..1b3afd2ff 100644 --- a/spec/react_on_rails/generators/install_generator_spec.rb +++ b/spec/react_on_rails/generators/install_generator_spec.rb @@ -139,6 +139,99 @@ end end + context "with --rspack" do + before(:all) { run_generator_test_with_args(%w[--rspack], package_json: true) } + + include_examples "base_generator", application_js: true + include_examples "no_redux_generator" + + it "creates bin/switch-bundler script" do + assert_file "bin/switch-bundler" do |content| + expect(content).to include("class BundlerSwitcher") + expect(content).to include("RSPACK_DEPS") + expect(content).to include("WEBPACK_DEPS") + end + end + + it "installs rspack dependencies in package.json" do + assert_file "package.json" do |content| + package_json = JSON.parse(content) + expect(package_json["dependencies"]).to include("@rspack/core") + expect(package_json["dependencies"]).to include("rspack-manifest-plugin") + expect(package_json["devDependencies"]).to include("@rspack/cli") + expect(package_json["devDependencies"]).to include("@rspack/plugin-react-refresh") + end + end + + it "does not install webpack-specific dependencies" do + assert_file "package.json" do |content| + package_json = JSON.parse(content) + expect(package_json["dependencies"]).not_to include("webpack") + expect(package_json["devDependencies"]).not_to include("webpack-cli") + expect(package_json["devDependencies"]).not_to include("@pmmmwh/react-refresh-webpack-plugin") + end + end + + it "generates unified webpack config with bundler detection" do + assert_file "config/webpack/development.js" do |content| + expect(content).to include("const { devServer, inliningCss, config } = require('shakapacker')") + expect(content).to include("if (config.assets_bundler === 'rspack')") + expect(content).to include("@rspack/plugin-react-refresh") + expect(content).to include("@pmmmwh/react-refresh-webpack-plugin") + end + end + + it "generates server webpack config with bundler variable" do + assert_file "config/webpack/serverWebpackConfig.js" do |content| + expect(content).to include("const bundler = config.assets_bundler === 'rspack'") + expect(content).to include("? require('@rspack/core')") + expect(content).to include(": require('webpack')") + expect(content).to include("new bundler.optimize.LimitChunkCountPlugin") + end + end + end + + context "with --rspack --typescript" do + before(:all) { run_generator_test_with_args(%w[--rspack --typescript], package_json: true) } + + include_examples "base_generator_common", application_js: true + include_examples "no_redux_generator" + + it "creates TypeScript component files with .tsx extension" do + assert_file "app/javascript/src/HelloWorld/ror_components/HelloWorld.client.tsx" + assert_file "app/javascript/src/HelloWorld/ror_components/HelloWorld.server.tsx" + end + + it "creates tsconfig.json file" do + assert_file "tsconfig.json" do |content| + config = JSON.parse(content) + expect(config["compilerOptions"]["jsx"]).to eq("react-jsx") + expect(config["compilerOptions"]["strict"]).to be true + expect(config["include"]).to include("app/javascript/**/*") + end + end + + it "installs both rspack and typescript dependencies" do + assert_file "package.json" do |content| + package_json = JSON.parse(content) + # Rspack dependencies + expect(package_json["dependencies"]).to include("@rspack/core") + expect(package_json["devDependencies"]).to include("@rspack/cli") + # TypeScript dependencies + expect(package_json["devDependencies"]).to include("typescript") + expect(package_json["devDependencies"]).to include("@types/react") + expect(package_json["devDependencies"]).to include("@types/react-dom") + end + end + + it "TypeScript component includes proper typing" do + assert_file "app/javascript/src/HelloWorld/ror_components/HelloWorld.client.tsx" do |content| + expect(content).to match(/interface HelloWorldProps/) + expect(content).to match(/React\.FC/) + end + end + end + context "with helpful message" do let(:expected) do GeneratorMessages.format_info(GeneratorMessages.helpful_message_after_installation)