|
| 1 | +# Migrating from Webpack to Rspack |
| 2 | + |
| 3 | +This guide documents the process of migrating a React on Rails project from Webpack to Rspack using Shakapacker 9. It covers all known issues and their solutions based on real-world migrations. |
| 4 | + |
| 5 | +## Prerequisites |
| 6 | + |
| 7 | +- Shakapacker 9.0.0 or later (Rspack support was added in v9) |
| 8 | +- Node.js 18+ (Node.js 22+ recommended) |
| 9 | +- Working React on Rails application using Webpack |
| 10 | + |
| 11 | +## Overview |
| 12 | + |
| 13 | +Rspack is a high-performance bundler written in Rust that aims to be drop-in compatible with Webpack. While mostly compatible, there are several configuration differences and breaking changes in Shakapacker 9 that require attention during migration. |
| 14 | + |
| 15 | +**Reference Implementation:** [react-webpack-rails-tutorial PR #680](https://github.com/shakacode/react-webpack-rails-tutorial/pull/680) |
| 16 | + |
| 17 | +## Step 1: Update Dependencies |
| 18 | + |
| 19 | +### Install Rspack |
| 20 | + |
| 21 | +Add Rspack core package: |
| 22 | + |
| 23 | +```bash |
| 24 | +yarn add -D @rspack/core |
| 25 | +``` |
| 26 | + |
| 27 | +### Update shakapacker.yml |
| 28 | + |
| 29 | +Configure Shakapacker to use Rspack as the bundler: |
| 30 | + |
| 31 | +```yaml |
| 32 | +# config/shakapacker.yml |
| 33 | +default: &default # ... existing configuration ... |
| 34 | + assets_bundler: rspack # Add this line |
| 35 | +``` |
| 36 | +
|
| 37 | +## Step 2: Fix CSS Modules (Breaking Change) |
| 38 | +
|
| 39 | +> ⚠️ **CRITICAL**: Shakapacker 9 changed the default CSS Modules configuration |
| 40 | +
|
| 41 | +### The Problem |
| 42 | +
|
| 43 | +Shakapacker 9 defaults CSS Modules to use named exports (`namedExport: true`). This breaks existing code that imports CSS modules as default exports: |
| 44 | + |
| 45 | +```javascript |
| 46 | +// This pattern breaks with Shakapacker 9 defaults |
| 47 | +import css from './Component.module.scss'; |
| 48 | +console.log(css.someClass); // undefined! |
| 49 | +``` |
| 50 | + |
| 51 | +**Error messages you might see:** |
| 52 | + |
| 53 | +- SSR: `Cannot read properties of undefined (reading 'someClassName')` |
| 54 | +- Build: `ESModulesLinkingWarning: export 'default' (imported as 'css') was not found in './Component.module.scss'` |
| 55 | + |
| 56 | +### The Solution |
| 57 | + |
| 58 | +Configure CSS loader to preserve default export behavior in your webpack config: |
| 59 | + |
| 60 | +```javascript |
| 61 | +// config/webpack/commonWebpackConfig.js |
| 62 | +const { generateWebpackConfig, merge } = require('shakapacker'); |
| 63 | +
|
| 64 | +const commonWebpackConfig = () => { |
| 65 | + const baseWebpackConfig = generateWebpackConfig(); |
| 66 | +
|
| 67 | + // Fix CSS modules to use default exports for backward compatibility |
| 68 | + // Shakapacker 9 defaults to namedExport: true which breaks existing code |
| 69 | + baseWebpackConfig.module.rules.forEach((rule) => { |
| 70 | + if (rule.use && Array.isArray(rule.use)) { |
| 71 | + const cssLoader = rule.use.find((loader) => { |
| 72 | + const loaderName = typeof loader === 'string' ? loader : loader?.loader; |
| 73 | + return loaderName?.includes('css-loader'); |
| 74 | + }); |
| 75 | +
|
| 76 | + if (cssLoader?.options?.modules) { |
| 77 | + cssLoader.options.modules.namedExport = false; |
| 78 | + cssLoader.options.modules.exportLocalsConvention = 'camelCase'; |
| 79 | + } |
| 80 | + } |
| 81 | + }); |
| 82 | +
|
| 83 | + return merge({}, baseWebpackConfig, commonOptions); |
| 84 | +}; |
| 85 | +
|
| 86 | +module.exports = commonWebpackConfig; |
| 87 | +``` |
| 88 | + |
| 89 | +> **Important:** This configuration must be inside the function so it applies to a fresh config each time the function is called. |
| 90 | + |
| 91 | +## Step 3: Update Server Bundle Configuration |
| 92 | + |
| 93 | +If you use server-side rendering (SSR), update your server webpack configuration. |
| 94 | + |
| 95 | +### Fix CSS Extract Plugin Filtering |
| 96 | + |
| 97 | +Rspack uses a different loader path for CSS extraction than Webpack: |
| 98 | + |
| 99 | +- **Webpack:** `mini-css-extract-plugin` |
| 100 | +- **Rspack:** `@rspack/core/dist/cssExtractLoader.js` |
| 101 | + |
| 102 | +Update your server config to filter both: |
| 103 | + |
| 104 | +```javascript |
| 105 | +// config/webpack/serverWebpackConfig.js |
| 106 | +const configureServer = (clientConfig) => { |
| 107 | + // ... other configuration ... |
| 108 | +
|
| 109 | + serverConfig.module.rules.forEach((rule) => { |
| 110 | + if (rule.use && Array.isArray(rule.use)) { |
| 111 | + // Filter out CSS extraction loaders for server bundle |
| 112 | + rule.use = rule.use.filter((item) => { |
| 113 | + let testValue; |
| 114 | + if (typeof item === 'string') { |
| 115 | + testValue = item; |
| 116 | + } else if (typeof item.loader === 'string') { |
| 117 | + testValue = item.loader; |
| 118 | + } |
| 119 | + // Handle both Webpack and Rspack CSS extract loaders |
| 120 | + return !( |
| 121 | + testValue?.match(/mini-css-extract-plugin/) || |
| 122 | + testValue?.includes('cssExtractLoader') || // Rspack loader |
| 123 | + testValue === 'style-loader' |
| 124 | + ); |
| 125 | + }); |
| 126 | + } |
| 127 | + }); |
| 128 | +
|
| 129 | + return serverConfig; |
| 130 | +}; |
| 131 | +``` |
| 132 | + |
| 133 | +### Preserve CSS Modules Configuration for SSR |
| 134 | + |
| 135 | +When configuring CSS modules for SSR (using `exportOnlyLocals`), merge the settings instead of replacing them: |
| 136 | + |
| 137 | +```javascript |
| 138 | +// Wrong - overwrites the namedExport setting |
| 139 | +if (cssLoader?.options) { |
| 140 | + cssLoader.options.modules = { exportOnlyLocals: true }; |
| 141 | +} |
| 142 | +
|
| 143 | +// Correct - preserves namedExport: false from common config |
| 144 | +if (cssLoader?.options?.modules) { |
| 145 | + cssLoader.options.modules = { |
| 146 | + ...cssLoader.options.modules, // Preserve existing settings |
| 147 | + exportOnlyLocals: true, |
| 148 | + }; |
| 149 | +} |
| 150 | +``` |
| 151 | + |
| 152 | +## Step 4: Bundler Auto-Detection Pattern |
| 153 | + |
| 154 | +For projects that need to support both Webpack and Rspack, use conditional logic: |
| 155 | + |
| 156 | +```javascript |
| 157 | +// config/webpack/commonWebpackConfig.js |
| 158 | +const { config } = require('shakapacker'); |
| 159 | +
|
| 160 | +// Auto-detect bundler from shakapacker config |
| 161 | +const bundler = config.assets_bundler === 'rspack' ? require('@rspack/core') : require('webpack'); |
| 162 | +
|
| 163 | +// Use for plugins that differ between bundlers |
| 164 | +clientConfig.plugins.push( |
| 165 | + new bundler.ProvidePlugin({ |
| 166 | + React: 'react', |
| 167 | + }), |
| 168 | +); |
| 169 | +
|
| 170 | +serverConfig.plugins.unshift(new bundler.optimize.LimitChunkCountPlugin({ maxChunks: 1 })); |
| 171 | +``` |
| 172 | + |
| 173 | +This approach: |
| 174 | + |
| 175 | +- Keeps all configs in the same `config/webpack/` directory |
| 176 | +- Makes differences between bundlers explicit |
| 177 | +- Simplifies debugging and maintenance |
| 178 | + |
| 179 | +## Step 5: Handle SWC React Runtime (If Using SWC) |
| 180 | + |
| 181 | +If you use SWC for transpilation and server-side rendering, you may need to use the classic React runtime: |
| 182 | + |
| 183 | +```javascript |
| 184 | +// config/swc.config.js |
| 185 | +const customConfig = { |
| 186 | + options: { |
| 187 | + jsc: { |
| 188 | + transform: { |
| 189 | + react: { |
| 190 | + runtime: 'classic', // Use 'classic' instead of 'automatic' |
| 191 | + refresh: env.isDevelopment && env.runningWebpackDevServer, |
| 192 | + }, |
| 193 | + }, |
| 194 | + }, |
| 195 | + }, |
| 196 | +}; |
| 197 | +``` |
| 198 | + |
| 199 | +**Why?** React on Rails SSR detects render function signatures. The automatic runtime's transformed output may not be detected correctly, causing errors like: |
| 200 | + |
| 201 | +``` |
| 202 | +Invalid call to renderToString. Possibly you have a renderFunction, |
| 203 | +a function that already calls renderToString, that takes one parameter. |
| 204 | +``` |
| 205 | +
|
| 206 | +## Step 6: Handle ReScript (If Applicable) |
| 207 | +
|
| 208 | +If your project uses ReScript: |
| 209 | +
|
| 210 | +### Add `.bs.js` Extension Resolution |
| 211 | +
|
| 212 | +```javascript |
| 213 | +// config/webpack/commonWebpackConfig.js |
| 214 | +const commonOptions = { |
| 215 | + resolve: { |
| 216 | + extensions: ['.css', '.ts', '.tsx', '.bs.js'], |
| 217 | + }, |
| 218 | +}; |
| 219 | +``` |
| 220 | + |
| 221 | +### Patch Broken Dependencies |
| 222 | + |
| 223 | +Some ReScript packages may not include compiled `.bs.js` files. Use `patch-package`: |
| 224 | + |
| 225 | +```bash |
| 226 | +yarn add -D patch-package postinstall-postinstall |
| 227 | +``` |
| 228 | + |
| 229 | +Add to `package.json`: |
| 230 | + |
| 231 | +```json |
| 232 | +{ |
| 233 | + "scripts": { |
| 234 | + "postinstall": "patch-package" |
| 235 | + } |
| 236 | +} |
| 237 | +``` |
| 238 | + |
| 239 | +## Complete Configuration Example |
| 240 | + |
| 241 | +Here's a complete example of a dual Webpack/Rspack compatible configuration: |
| 242 | + |
| 243 | +```javascript |
| 244 | +// config/webpack/commonWebpackConfig.js |
| 245 | +const { generateWebpackConfig, merge, config } = require('shakapacker'); |
| 246 | + |
| 247 | +// Auto-detect bundler |
| 248 | +const bundler = config.assets_bundler === 'rspack' ? require('@rspack/core') : require('webpack'); |
| 249 | + |
| 250 | +const commonOptions = { |
| 251 | + resolve: { |
| 252 | + extensions: ['.css', '.ts', '.tsx'], |
| 253 | + }, |
| 254 | +}; |
| 255 | + |
| 256 | +/** |
| 257 | + * Generate common webpack configuration with CSS modules fix. |
| 258 | + * Must be called as a function to get fresh config each time. |
| 259 | + */ |
| 260 | +const commonWebpackConfig = () => { |
| 261 | + const baseWebpackConfig = generateWebpackConfig(); |
| 262 | + |
| 263 | + // Fix CSS modules for backward compatibility with Shakapacker 9 |
| 264 | + baseWebpackConfig.module.rules.forEach((rule) => { |
| 265 | + if (rule.use && Array.isArray(rule.use)) { |
| 266 | + const cssLoader = rule.use.find((loader) => { |
| 267 | + const loaderName = typeof loader === 'string' ? loader : loader?.loader; |
| 268 | + return loaderName?.includes('css-loader'); |
| 269 | + }); |
| 270 | + |
| 271 | + if (cssLoader?.options?.modules) { |
| 272 | + cssLoader.options.modules.namedExport = false; |
| 273 | + cssLoader.options.modules.exportLocalsConvention = 'camelCase'; |
| 274 | + } |
| 275 | + } |
| 276 | + }); |
| 277 | + |
| 278 | + return merge({}, baseWebpackConfig, commonOptions); |
| 279 | +}; |
| 280 | + |
| 281 | +module.exports = commonWebpackConfig; |
| 282 | +module.exports.bundler = bundler; |
| 283 | +``` |
| 284 | +
|
| 285 | +```javascript |
| 286 | +// config/webpack/serverWebpackConfig.js |
| 287 | +const { merge } = require('shakapacker'); |
| 288 | + |
| 289 | +/** |
| 290 | + * Configure server-side rendering bundle. |
| 291 | + * Handles both Webpack and Rspack CSS extraction loaders. |
| 292 | + */ |
| 293 | +const configureServer = (clientConfig) => { |
| 294 | + const serverConfig = merge({}, clientConfig); |
| 295 | + |
| 296 | + serverConfig.module.rules.forEach((rule) => { |
| 297 | + if (rule.use && Array.isArray(rule.use)) { |
| 298 | + // Filter CSS extraction loaders (different paths for Webpack vs Rspack) |
| 299 | + rule.use = rule.use.filter((item) => { |
| 300 | + let testValue; |
| 301 | + if (typeof item === 'string') { |
| 302 | + testValue = item; |
| 303 | + } else if (typeof item.loader === 'string') { |
| 304 | + testValue = item.loader; |
| 305 | + } |
| 306 | + return !( |
| 307 | + testValue?.match(/mini-css-extract-plugin/) || |
| 308 | + testValue?.includes('cssExtractLoader') || |
| 309 | + testValue === 'style-loader' |
| 310 | + ); |
| 311 | + }); |
| 312 | + |
| 313 | + // Configure CSS modules for SSR (exportOnlyLocals) |
| 314 | + const cssLoader = rule.use.find((loader) => { |
| 315 | + const loaderName = typeof loader === 'string' ? loader : loader?.loader; |
| 316 | + return loaderName?.includes('css-loader'); |
| 317 | + }); |
| 318 | + |
| 319 | + if (cssLoader?.options?.modules) { |
| 320 | + cssLoader.options.modules = { |
| 321 | + ...cssLoader.options.modules, // Preserve namedExport: false |
| 322 | + exportOnlyLocals: true, |
| 323 | + }; |
| 324 | + } |
| 325 | + } |
| 326 | + }); |
| 327 | + |
| 328 | + return serverConfig; |
| 329 | +}; |
| 330 | + |
| 331 | +module.exports = configureServer; |
| 332 | +``` |
| 333 | +
|
| 334 | +## Troubleshooting |
| 335 | +
|
| 336 | +### CSS Modules Return `undefined` in SSR |
| 337 | +
|
| 338 | +**Cause:** CSS extraction loader not filtered from server bundle, or CSS modules configuration being overwritten. |
| 339 | +
|
| 340 | +**Solution:** |
| 341 | +
|
| 342 | +1. Ensure `cssExtractLoader` is filtered (see Step 3) |
| 343 | +2. Ensure CSS modules config is merged, not replaced |
| 344 | +
|
| 345 | +### Tests Pass Locally But Fail Intermittently in CI |
| 346 | +
|
| 347 | +**Cause:** Incomplete CSS extraction filtering causes non-deterministic behavior. |
| 348 | +
|
| 349 | +**Solution:** Add the `cssExtractLoader` filter for Rspack (see Step 3). |
| 350 | +
|
| 351 | +### Module Not Found Errors |
| 352 | +
|
| 353 | +**Cause:** Rspack may have stricter module resolution. |
| 354 | +
|
| 355 | +**Solution:** |
| 356 | +
|
| 357 | +1. Check `resolve.extensions` in webpack config |
| 358 | +2. Ensure all required file extensions are listed |
| 359 | +3. For ReScript, add `.bs.js` extension |
| 360 | +
|
| 361 | +### Build Warnings About Named Exports |
| 362 | +
|
| 363 | +**Warning:** `export 'default' (imported as 'css') was not found` |
| 364 | +
|
| 365 | +**Cause:** Shakapacker 9's `namedExport: true` default. |
| 366 | +
|
| 367 | +**Solution:** Apply the CSS modules fix in Step 2. |
| 368 | +
|
| 369 | +## Performance Benefits |
| 370 | +
|
| 371 | +After migrating to Rspack, you should see significant build time improvements: |
| 372 | +
|
| 373 | +- **Development builds:** 2-5x faster |
| 374 | +- **Production builds:** 2-3x faster |
| 375 | +- **Hot Module Replacement:** Near-instant updates |
| 376 | +
|
| 377 | +## Additional Resources |
| 378 | +
|
| 379 | +- [Shakapacker Rspack Support Issue](https://github.com/shakacode/shakapacker/issues/693) |
| 380 | +- [Rspack Documentation](https://rspack.dev/) |
| 381 | +- [Shakapacker Documentation](https://github.com/shakacode/shakapacker) |
0 commit comments