Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a951f4c
Update to Shakapacker 9.1.0 and migrate to Rspack
justin808 Oct 9, 2025
879d171
Add missing i18n translation files
justin808 Oct 10, 2025
087ec70
Fix Ruby version mismatch for CI
justin808 Oct 10, 2025
5d85f15
Fix SSR by using classic React runtime in SWC
justin808 Oct 10, 2025
3fe61f0
Fix CSS modules config for server bundle
justin808 Oct 10, 2025
fbc5781
Add .bs.js extension to resolve extensions for ReScript
justin808 Oct 11, 2025
76921b8
Add patch for rescript-json-combinators to generate .bs.js files
justin808 Oct 11, 2025
012b0b7
Fix yarn.lock and patch file for rescript-json-combinators
justin808 Oct 11, 2025
1685fb4
Fix CSS modules to use default exports for ReScript compatibility
justin808 Oct 11, 2025
28014b2
Move CSS modules fix into function to ensure it applies on each call
justin808 Oct 11, 2025
3da3dfc
Fix server bundle to properly filter Rspack CSS extract loader
justin808 Oct 12, 2025
71b934a
Remove generated i18n files that should be gitignored
justin808 Oct 12, 2025
752919b
Consolidate Rspack config into webpack directory with conditionals
justin808 Oct 12, 2025
4c761bb
Add bundler auto-detection to all webpack config files
justin808 Oct 12, 2025
431a8ee
Add comprehensive documentation and address code review feedback
justin808 Oct 12, 2025
2e03f56
Add performance benchmarks to README
justin808 Oct 12, 2025
5f92988
Correct performance benchmarks to show actual bundler times
justin808 Oct 12, 2025
0ab9eac
Refactor bundler detection and improve documentation
justin808 Oct 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "3.4.6"

gem "react_on_rails", "16.1.1"
gem "shakapacker", "9.0.0.beta.8"
gem "shakapacker", "9.1.0"

# Bundle edge Rails instead: gem "rails", github: "rails/rails"
gem "listen"
Expand Down
6 changes: 3 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ GEM
websocket (~> 1.0)
semantic_range (3.1.0)
sexp_processor (4.17.1)
shakapacker (9.0.0.beta.8)
shakapacker (9.1.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
Expand Down Expand Up @@ -493,7 +493,7 @@ DEPENDENCIES
scss_lint
sdoc
selenium-webdriver (~> 4)
shakapacker (= 9.0.0.beta.8)
shakapacker (= 9.1.0)
spring
spring-commands-rspec
stimulus-rails (~> 1.3)
Expand All @@ -502,7 +502,7 @@ DEPENDENCIES
web-console

RUBY VERSION
ruby 3.4.6p54
ruby 3.4.6p32

BUNDLED WITH
2.4.17
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,46 @@ See package.json and Gemfile for versions
+ **Testing Mode**: When running tests, it is useful to run `foreman start -f Procfile.spec` in order to have webpack automatically recompile the static bundles. Rspec is configured to automatically check whether or not this process is running. If it is not, it will automatically rebuild the webpack bundle to ensure you are not running tests on stale client code. This is achieved via the `ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config)`
line in the `rails_helper.rb` file. If you are using this project as an example and are not using RSpec, you may want to implement similar logic in your own project.

## Webpack
## Webpack and Rspack

_Converted to use Shakapacker webpack configuration_.
_Converted to use Shakapacker with support for both Webpack and Rspack bundlers_.
Comment on lines +168 to +170
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the Table of Contents anchor

Renaming the heading to “Webpack and Rspack” means the TOC link [Webpack](#webpack) no longer resolves. Please update the TOC entry to match the new slug (#webpack-and-rspack) to avoid a broken navigation link.

-+ [Webpack](#webpack)
++ [Webpack and Rspack](#webpack-and-rspack)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In README.md around lines 168 to 170, the Table of Contents still links to
[Webpack](#webpack) but the heading was renamed to "Webpack and Rspack", so
update the TOC entry to use the new anchor `#webpack-and-rspack` (or rename the
TOC label to match) so the link resolves; ensure the TOC text and slug match
exactly the heading (lowercase, spaces to hyphens) and update any other
occurrences of the old `#webpack` anchor.


This project supports both Webpack and Rspack as JavaScript bundlers via [Shakapacker](https://github.com/shakacode/shakapacker). Switch between them by changing the `assets_bundler` setting in `config/shakapacker.yml`:

```yaml
# Use Rspack (default - faster builds)
assets_bundler: rspack

# Or use Webpack (classic, stable)
assets_bundler: webpack
```

### Performance Comparison

Measured bundler compile times for this project (client + server bundles):

| Build Type | Webpack | Rspack | Improvement |
|------------|---------|--------|-------------|
| Development | ~3.1s | ~1.0s | **~3x faster** |
| Production (cold) | ~22s | ~10.7s | **~2x faster** |

**Benefits of Rspack:**
- 67% faster development builds (saves ~2.1s per incremental build)
- 51% faster production builds (saves ~11s on cold builds)
- Faster incremental rebuilds during development
- Reduced CI build times
- Drop-in replacement - same configuration files work for both bundlers

_Note: These are actual bundler compile times. Total build times including package manager overhead may vary._

### Configuration Files

All bundler configuration is in `config/webpack/`:
- `webpack.config.js` - Main entry point (auto-detects Webpack or Rspack)
- `commonWebpackConfig.js` - Shared configuration
- `clientWebpackConfig.js` - Client bundle settings
- `serverWebpackConfig.js` - Server-side rendering bundle
- `development.js`, `production.js`, `test.js` - Environment-specific settings

### Additional Resources
- [Webpack Docs](https://webpack.js.org/)
Expand Down
8 changes: 3 additions & 5 deletions bin/shakapacker
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@

ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__)
ENV["APP_ROOT"] ||= File.expand_path("..", __dir__)

require "bundler/setup"
require "shakapacker"
require "shakapacker/webpack_runner"
require "shakapacker/runner"

APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Shakapacker::WebpackRunner.run(ARGV)
end
Shakapacker::Runner.run(ARGV)
1 change: 1 addition & 0 deletions config/shakapacker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ default: &default
webpack_compile_output: true
nested_entries: true
javascript_transpiler: swc
assets_bundler: rspack

# Additional paths webpack should lookup modules
# ['app/assets', 'engine/foo/app/assets']
Expand Down
4 changes: 2 additions & 2 deletions config/swc.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const customConfig = {
loose: false,
transform: {
react: {
// Use automatic runtime (React 17+) - no need to import React
runtime: 'automatic',
// Use classic runtime for better SSR compatibility with React on Rails
runtime: 'classic',
// Enable React Fast Refresh in development
refresh: env.isDevelopment && env.runningWebpackDevServer,
},
Expand Down
44 changes: 44 additions & 0 deletions config/webpack/bundlerUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Bundler utilities for automatic Webpack/Rspack detection.
*
* Shakapacker 9.1+ supports both Webpack and Rspack as bundlers.
* The bundler is selected via config/shakapacker.yml:
* assets_bundler: webpack # or 'rspack'
*/

const { config } = require('shakapacker');

/**
* Gets the appropriate bundler module based on shakapacker.yml configuration.
*
* @returns {Object} Either webpack or @rspack/core module
*/
const getBundler = () => {
return config.assets_bundler === 'rspack'
? require('@rspack/core')
: require('webpack');
};

/**
* Checks if the current bundler is Rspack.
*
* @returns {boolean} True if using Rspack, false if using Webpack
*/
const isRspack = () => config.assets_bundler === 'rspack';

/**
* Gets the appropriate CSS extraction plugin for the current bundler.
*
* @returns {Object} Either mini-css-extract-plugin (Webpack) or CssExtractRspackPlugin (Rspack)
*/
const getCssExtractPlugin = () => {
return isRspack()
? getBundler().CssExtractRspackPlugin
: require('mini-css-extract-plugin');
};

module.exports = {
getBundler,
isRspack,
getCssExtractPlugin,
};
9 changes: 7 additions & 2 deletions config/webpack/client.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
const devBuild = process.env.NODE_ENV === 'development';
const isHMR = process.env.WEBPACK_DEV_SERVER === 'TRUE';
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const webpack = require('webpack');
const { config } = require('shakapacker');
const environment = require('./environment');

// Auto-detect bundler from shakapacker config and load the appropriate library
const bundler = config.assets_bundler === 'rspack'
? require('@rspack/core')
: require('webpack');

if (devBuild && !isHMR) {
environment.loaders.get('sass').use.find((item) => item.loader === 'sass-loader').options.sourceMap = false;
}

environment.plugins.append(
'Provide',
new webpack.ProvidePlugin({
new bundler.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
jquery: 'jquery',
Expand Down
5 changes: 3 additions & 2 deletions config/webpack/clientWebpackConfig.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// The source code including full typescript support is available at:
// https://github.com/shakacode/react_on_rails_tutorial_with_ssr_and_hmr_fast_refresh/blob/master/config/webpack/clientWebpackConfig.js

const webpack = require('webpack');
const commonWebpackConfig = require('./commonWebpackConfig');
const { getBundler } = require('./bundlerUtils');

const configureClient = () => {
const bundler = getBundler();
const clientConfig = commonWebpackConfig();

clientConfig.plugins.push(
new webpack.ProvidePlugin({
new bundler.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
ActionCable: '@rails/actioncable',
Expand Down
115 changes: 73 additions & 42 deletions config/webpack/commonWebpackConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
// Common configuration applying to client and server configuration
const { generateWebpackConfig, merge } = require('shakapacker');

const baseClientWebpackConfig = generateWebpackConfig();
const commonOptions = {
resolve: {
extensions: ['.css', '.ts', '.tsx'],
// Add .bs.js extension for ReScript-compiled modules
extensions: ['.css', '.ts', '.tsx', '.bs.js'],
},
};

// add sass resource loader
// Sass resource loader config - globally imports app variables
const sassLoaderConfig = {
loader: 'sass-resources-loader',
options: {
Expand All @@ -20,56 +20,87 @@ const sassLoaderConfig = {
};

const ignoreWarningsConfig = {
// React 19 uses react-dom/client but not all deps have migrated yet
ignoreWarnings: [/Module not found: Error: Can't resolve 'react-dom\/client'/],
};

const scssConfigIndex = baseClientWebpackConfig.module.rules.findIndex((config) =>
'.scss'.match(config.test) && config.use,
);
/**
* Generates the common webpack/rspack configuration used by both client and server bundles.
*
* IMPORTANT: This function calls generateWebpackConfig() fresh on each invocation, so mutations
* to the returned config are safe and won't affect other builds. The config is regenerated
* for each build (client, server, etc.).
*
* Key customizations:
* - CSS Modules: Configured for default exports (namedExport: false) for backward compatibility
* - Sass: Configured with modern API and global variable imports
* - ReScript: Added .bs.js to resolve extensions
*
* @returns {Object} Webpack/Rspack configuration object (auto-detected based on shakapacker.yml)
*/
const commonWebpackConfig = () => {
// Generate fresh config - safe to mutate since it's a new object each time
const baseWebpackConfig = generateWebpackConfig();

if (scssConfigIndex === -1) {
console.warn('No SCSS rule with use array found in webpack config');
} else {
// Configure sass-loader to use the modern API
const scssRule = baseClientWebpackConfig.module.rules[scssConfigIndex];
const sassLoaderIndex = scssRule.use.findIndex((loader) => {
if (typeof loader === 'string') {
return loader.includes('sass-loader');
// Fix CSS Modules to use default exports for backward compatibility
// Shakapacker 9 changed default to namedExport: true, breaking existing imports like:
// import css from './file.module.scss'
// This ensures css is an object with properties, not undefined
baseWebpackConfig.module.rules.forEach((rule) => {
if (rule.use && Array.isArray(rule.use)) {
const cssLoader = rule.use.find((loader) => {
const loaderName = typeof loader === 'string' ? loader : loader?.loader;
return loaderName?.includes('css-loader');
});

if (cssLoader?.options?.modules) {
cssLoader.options.modules.namedExport = false;
cssLoader.options.modules.exportLocalsConvention = 'camelCase';
}
}
return loader.loader && loader.loader.includes('sass-loader');
});

if (sassLoaderIndex !== -1) {
const sassLoader = scssRule.use[sassLoaderIndex];
if (typeof sassLoader === 'string') {
scssRule.use[sassLoaderIndex] = {
loader: sassLoader,
options: {
api: 'modern'
}
};
} else {
sassLoader.options = sassLoader.options || {};
sassLoader.options.api = 'modern';
}
}
const scssConfigIndex = baseWebpackConfig.module.rules.findIndex((config) =>
'.scss'.match(config.test) && config.use,
);
Comment on lines +63 to +65
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Incorrect regex matching logic (regression).

The expression '.scss'.match(config.test) is incorrect because config.test is a RegExp object, not a string pattern. This will fail to match SCSS rules and cause the SCSS configuration to be skipped entirely.

Note: A previous review comment indicated this was fixed in commit 431a8ee, but the current code still contains the bug, suggesting either a regression or the fix wasn't applied.

Apply this diff to fix the logic:

-  const scssConfigIndex = baseWebpackConfig.module.rules.findIndex((config) =>
-    '.scss'.match(config.test) && config.use,
-  );
+  const scssConfigIndex = baseWebpackConfig.module.rules.findIndex(
+    (config) => config.test?.test('.scss') && config.use,
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const scssConfigIndex = baseWebpackConfig.module.rules.findIndex((config) =>
'.scss'.match(config.test) && config.use,
);
const scssConfigIndex = baseWebpackConfig.module.rules.findIndex(
(config) => config.test?.test('.scss') && config.use,
);
🧰 Tools
🪛 ESLint

[error] 63-64: Replace (config)·=>⏎··· with ⏎····(config)·=>

(prettier/prettier)

🤖 Prompt for AI Agents
In config/webpack/commonWebpackConfig.js around lines 63 to 65, the findIndex
uses "'.scss'.match(config.test)" which is wrong because config.test is a
RegExp; change the predicate to verify config.test is a RegExp and call its test
method (e.g., config.test instanceof RegExp && config.test.test('.scss')) and
keep the config.use check so the SCSS rule is correctly matched and not skipped.


// Fix css-loader configuration for CSS modules if namedExport is enabled
// When namedExport is true, exportLocalsConvention must be camelCaseOnly or dashesOnly
const cssLoader = scssRule.use.find((loader) => {
const loaderName = typeof loader === 'string' ? loader : loader?.loader;
return loaderName?.includes('css-loader');
});
if (scssConfigIndex === -1) {
console.warn('No SCSS rule with use array found in webpack config');
// Not throwing error since config might work without SCSS
} else {
// Configure sass-loader to use the modern API
const scssRule = baseWebpackConfig.module.rules[scssConfigIndex];
const sassLoaderIndex = scssRule.use.findIndex((loader) => {
if (typeof loader === 'string') {
return loader.includes('sass-loader');
}
return loader.loader && loader.loader.includes('sass-loader');
});

if (cssLoader?.options?.modules?.namedExport) {
cssLoader.options.modules.exportLocalsConvention = 'camelCaseOnly';
}
if (sassLoaderIndex !== -1) {
const sassLoader = scssRule.use[sassLoaderIndex];
if (typeof sassLoader === 'string') {
scssRule.use[sassLoaderIndex] = {
loader: sassLoader,
options: {
// Use modern API for better performance and to support sass-resources-loader
// The modern API uses the Sass JavaScript API instead of the legacy Node API
api: 'modern'
}
};
} else {
sassLoader.options = sassLoader.options || {};
// Use modern API for better performance and to support sass-resources-loader
// The modern API uses the Sass JavaScript API instead of the legacy Node API
sassLoader.options.api = 'modern';
}
}

baseClientWebpackConfig.module.rules[scssConfigIndex].use.push(sassLoaderConfig);
}
baseWebpackConfig.module.rules[scssConfigIndex].use.push(sassLoaderConfig);
}

// Copy the object using merge b/c the baseClientWebpackConfig and commonOptions are mutable globals
const commonWebpackConfig = () => merge({}, baseClientWebpackConfig, commonOptions, ignoreWarningsConfig);
return merge({}, baseWebpackConfig, commonOptions, ignoreWarningsConfig);
};

module.exports = commonWebpackConfig;

10 changes: 7 additions & 3 deletions config/webpack/server.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
const merge = require('webpack-merge');

const devBuild = process.env.NODE_ENV === 'production' ? 'production' : 'development';
const webpack = require('webpack');

const { config } = require('shakapacker');
const environment = require('./environment');

// Auto-detect bundler from shakapacker config and load the appropriate library
const bundler = config.assets_bundler === 'rspack'
? require('@rspack/core')
: require('webpack');

// React Server Side Rendering shakapacker config
// Builds a Node compatible file that React on Rails can load, never served to the client.

environment.plugins.insert(
'DefinePlugin',
new webpack.DefinePlugin({
new bundler.DefinePlugin({
TRACE_TURBOLINKS: true,
'process.env': {
NODE_ENV: devBuild,
Expand Down
Loading