Webpacker to jsbundling-rails migration

Preface

Recently, I took on a challenge to migrate my company's javascript bundling framework away from webpacker and into jsbundling-rails + webpack as part of our efforts to upgrade to Rails 7. My application had these requirements:

  • Have to implement code splitting

  • Able to create separate builds for development and production environments

  • Development build has to spawn a dev server with hot reload

I went into this challenge blindly, not knowing anything about webpacker and javascript bundling. In the end, I managed to complete the task successfully but I had LOTS of difficulties and frustrations in the process.

This article aims to document my insights and learnings in as much detail as possible. This includes some introduction to webpacker, webpack, and my thought process for choosing jsbundling-rails. I hope this article could help guide some developers out there who are struggling with a similar task.

The final code (complete skeleton) is available in this repository. You can follow the walkthrough below with the final code for reference :)

Introduction

First things first, in case you don't know, Webpacker is not the same as Webpack!

Webpacker is a framework that bundles JavaScript, CSS, and other assets into a single file, making it easier to manage dependencies and optimize performance. Under the hood, Webpacker works by leveraging Webpack, a popular module bundler for JavaScript applications.

With Webpacker, developers can organize their JavaScript files into modules and use import and export statements to manage dependencies between modules. Webpacker will then use Webpack to bundle these modules into a single file, which can be included in the Rails asset pipeline and served to the client.

Webpacker also supports advanced features like code splitting, which allows developers to split their code into smaller chunks that can be loaded on demand, improving performance and reducing load times.

The Concern / Problem

Some developers found Webpacker to be overly complex and difficult to work with, especially for smaller projects. With Webpacker, much of the configuration and management of JavaScript assets is abstracted away, which can make it difficult to diagnose problems when they arise. As such, there could be a relatively steep learning curve for developers who are not familiar with its conventions and processes. Additionally, because Webpacker is tightly integrated with the Rails asset pipeline, it may not be as flexible or customizable as standalone Webpack configurations.

With this in mind, as of Rails 7, Webpacker has been retired as the default JavaScript bundler and replaced with a new approach called JavaScript "Stimulus". The Rails team wanted to reduce the amount of JavaScript abstraction in the default stack (i.e. make it less of a “black box”) to make it easier for developers to understand and manage their JavaScript code. Overall, the retirement of Webpacker reflects a shift towards a simpler and more streamlined approach to managing JavaScript assets in Rails.

Considerations

Given the retirement of Webpacker, we have 4 options to choose from:

Import Maps

Import maps is a new feature in Rails 7+ that allows developers to manage JavaScript dependencies more easily by mapping modules to URLs. This simplifies the process of including JavaScript modules in a project and can improve performance by reducing the amount of code that needs to be loaded on each page.

However, because import maps are designed to manage dependencies at runtime rather than at build time, they do not work with libraries or frameworks that rely on more traditional dependency management approaches and compilation. For example, we can’t utilize useful development tools such as importing libraries using es6 Typescript or using JSX for React applications because these development frameworks require compilation to transform the codes into a plain javascript file. Import maps bypasses this compilation step and therefore restricts you from using these widely used development frameworks, which is not ideal.

Use Webpacker as-is

From Webpacker’s official documentation:

“We will continue to address security issues on the Ruby side of the gem according to the normal maintenance schedule of Rails. But we will not be updating the gem to include newer versions of the JavaScript libraries. This pertains to the v5 edition of this gem that was included by default with previous versions of Rails.”

Staying with Webpacker would not be future-proof as it will no longer be actively maintained to keep up with newer libraries.

Move to Shakapacker

Shakapacker is the official successor of Webpacker that will be actively maintained as a fork by the ShakaCode team (different from Rails team). We could’ve made the move over to Shakapacker but this would create that “black box” kind of situation again for our Javascript bundling process. We did not need all the features offered by Webpacker anyways, so by moving into Shakapacker we may be walking into another overcomplicated & bloated bundling framework

Move to jsbundling-rails

jsbundling-rails is a new JavaScript bundling framework that is designed to be much simpler (barebone) and lightweight compared to Webpacker, which makes it easier for developers to understand and use. Additionally, jsbundling-rails provides a more flexible and customizable approach to JavaScript asset management while utilizing the in-built Rails sprockets asset pipeline. Overall, this approach would break the “black box” situation and allow us to configure Webpack from scratch and tailor it specifically to our application’s needs.

More detailed comparison points can be found here.

Chosen Solution & Implementation

Given the considerations above, I decided to go with jsbundling-rails and chose webpack as my bundler to take more control of my codebase's Javascript bundling process and be more lightweight.

Scripts setup

  • Add jsbundling-rails to Gemfile

  • Add these 2 scripts to package.json:

{
    ...,
  "scripts": {
    ...,
    "dev": "webpack serve --config ./webpack/webpack.config.js --mode development --progress",
    "build": "webpack --config ./webpack/webpack.config.js --mode production --progress"
  },
    ...
}

dev script will invoke webpack-dev-server (with hot reload 🔥) by using the command webpack serve, while build script (for production) will just compile and bundle our files without creating a dev server

We used 3 flags in the scripts, which are:

  • --config to point to our webpack configurations in webpack.config.js file

  • --mode to specify which mode we are in e.g.development or production

  • --progress to show the progress of webpack’s bundling process / webpack-dev-server’s loading process in the terminal

Remove Webpacker stuff

  • Remove bin/webpack, bin/webpack-dev-server, config/webpacker.yml , and webpacker gem from Gemfile

  • Remove from config (if applicable)

# config/initializers/assets.rb

- # Add Yarn node_modules folder to the asset load path.
- Rails.application.config.assets.paths << Rails.root.join('node_modules')
  • Global search and remove anything related to webpacker (e.g. config.webpacker.check_yarn_integrity, etc.)

  • Run bundle install

Webpack file system design

I highly recommend you follow the walkthrough below while referring to the files in the final skeleton code, just to minimize the chance of getting lost :) Here we go!

Entry

Create an entry config file webpack/webpack.config.js which will be directly called by our dev and build scripts. I designed the following structure:

const baseConfig = require('./base')
const devConfig = require('./development')
const prodConfig = require('./production')

module.exports = (_, argv) => {
  let webpackConfig = baseConfig(argv.mode);

  if (argv.mode === 'development') {
    devConfig(webpackConfig);
  }

  if (argv.mode === 'production') {
    prodConfig(webpackConfig);
  }

  return webpackConfig;
}

The idea for this structure is to create a shared configuration (base) which can then be modified according to the --mode flag that we specify when calling this config file (i.e. either development or production)

I made these configs to be functional components for better readability.

Config

Create a global config file webpack/config.js which mimics the base settings of webpacker.yml

const sourcePath = "app/javascript";
const sourceEntryPath = "packs";
const publicRootPath = "public";
const publicOutputPath = "packs";
const additionalPaths = [
  "app/assets/javascript"
];
const devServerPort = 3035;

module.exports = {
  sourcePath,
  sourceEntryPath,
  publicRootPath,
  publicOutputPath,
  additionalPaths,
  devServerPort
}

We are going to use these values multiple times in our configuration later on, so it’s good to have these configs in one place for DRY-ness and ease of maintenance in the future.

Base

There are TONS of configuration that you can set for webpack. However, we try to use as minimal configuration as possible to prevent unnecessary processing. This will be the meat of this article because I will explain each of these configurations as detailed as possible.

With that in mind, this is the skeleton structure of my base config webpack/base.js:

const sharedWebpackConfig = (mode) => {
  const isProduction = (mode === "production");

  return {
    mode: // --mode flag from package.json,
    entry: // entry objects
    optimization: // optimization rules
    resolve: {
      extensions: // define all extensions that we use in codebase
      modules: // define paths to read modules (imports, etc.)
    },
    resolveLoader: {
      modules: [ 'node_modules' ], // default settings
    },
    module: {
      strictExportPresence: true,
      rules: // define rules such as loaders for different extensions
    },
    output: // output settings
    plugins: // define webpack & 3rd party plugins
  }
}

module.exports = sharedWebpackConfig;

Webpacker used to do a lot of “magic” in setting up the base webpack config under the hood for us and in the past we could simply call the base config like this:

const { webpackConfig } = require('@rails/webpacker')

But since we’re not using webpacker anymore, let’s dive into each of these configuration items more closely, because now we have to handle all of these configs manually.

  • entry

    This is where we provide webpack with our entry points. In my case, all of my entry points were stored in app/javascript/packs. In the final sample code, I created a dummy application1.js and application2.js in app/javascript/packs to simulate multiple entrypoints.

    When we were using webpacker, we just need to define sourcePathand sourceEntryPath (which was defined as app/javascript and packs respectively in webpacker.yml) then webpacker will go through every filename in app/javascript/packs and magically generate an object with this structure:

      {
          entrypoint1: "path/to/entrypoint1.js",
          entrypoint2: "path/to/entrypoint2.js",
          // if we also have css file for a specific entrypoint in the folder
          entrypoint3: [
              "path/to/entrypoint3.js",
              "path/to/entrypoint3.css",
          ],
          ...
      }
    

    To generate the same structure, I created a similar helper function:

      const { join , resolve } = require("path");
      const fs = require("fs");
      const { sourcePath, sourceEntryPath } = require("./config")
    
      const getEntryObject = () => {
        const packsPath = resolve(process.cwd(), join(sourcePath, sourceEntryPath));
        const entryPoints = {}
    
        fs.readdirSync(packsPath).forEach((packNameWithExtension) => {
          const packName = packNameWithExtension.replace(".js", "").replace(".scss", "");
    
          if (entryPoints[packName]) {
            entryPoints[packName] = [entryPoints[packName], packsPath + "/" + packNameWithExtension];
          } else {
            entryPoints[packName] = packsPath + "/" + packNameWithExtension;
          }
        });
    
        return entryPoints;
      }
    

    Then we can just attach getEntryObject() to our entry setting in webpack config

      const sharedWebpackConfig = (mode) => {
        return {
              ...
          entry: getEntryObject(),
              ...
          }
      }
    
      module.exports = sharedWebpackConfig;
    
  • optimization

    This is where I configured my code-splitting feature. I passed the following optimization rules to sharedWebpackConfig :

      ...
      optimization: {
        runtimeChunk: false,
        splitChunks: {
          chunks(chunk) {
            return chunk.name !== 'application2'; // if you want to exclude code splitting for certain packs
          },
        }
      },
      ...
    

    In the sample code above, I considered an edge case where you might want to exclude code-splitting for certain packs through a custom splitChunks logic. If you don't have such an edge case, you can delete the whole splitChunks object.

  • resolve

    There are 2 parts to the resolve rule.

    First, we need to tell webpack what are all the possible extensions that webpack can encounter in our codebase, and we put that as an array under resolve.extensions. For example, I have coffeescript, javascript, typescript, css, sass, image files, etc. in my codebase so I have to list down all of their extensions.

    Second, under resolve.modules, we need to tell webpack where are all the possible locations they can find these files at.

    Webpacker abstracts this part away by automatically adding the absolute path of your sourcePath and every item under additionalPaths that were specified in webpacker.yml. It also adds node_modules so that your files can import installed 3rd party libraries. To replicate the same behaviour, I created a simple helper called getModulePaths()

      const { sourcePath, additionalPaths } = require("./config")
    
      const getModulePaths = () => {
        // Add absolute source path
        const result = [resolve(process.cwd(), sourcePath)]
    
        // Add absolute path of every single additional path
        additionalPaths.forEach((additionalPath) => {
          result.push(resolve(process.cwd(), additionalPath))
        })
    
        // Add node modules
        result.push("node_modules")
    
        return result;
      }
    

    So in the end, my resolve setting looks something like this

      ...
      resolve: {
        extensions: [
          '.coffee', '.js.coffee', '.erb',
          '.js', '.jsx', '.ts', '.js.ts',
          '.vue', '.sass', '.scss', '.css',
          '.png', '.svg', '.gif', '.jpeg', '.jpg'
        ],
        modules: getModulePaths(),
      },
      ...
    
  • resolveLoader

    This set of options is identical to the resolve property set above, but is used only to resolve webpack's loader packages. We leave it as the default settings.

      ...
      resolveLoader: {
        modules: [ 'node_modules' ], // default settings
      },
      ...
    
  • module

    module.strictExportPresence makes missing exports an error instead of a warning. I wanted this stricter measure so I set it to true

      ...
      module: {
        strictExportPresence: true,
        rules: // define rules such as loaders for different extensions
      },
      ...
    

    module.rules is where we define which loaders to use for various extensions. We have quite a few rules for each file type (e.g. raw, file, css, sass, coffescript, typescript, etc.). Since the rules are quite long, I moved this set of rules to a separate file webpack/rules.js with the following content:

      // webpack/rules.js
    
      module.exports = () => [
          // Raw
          {
            test: [ /\\.html$/ ],
            exclude: [ /\\.(js|mjs|jsx|ts|tsx)$/ ],
            type: 'asset/source'
          },
          // File
          {
            test: [
              /\\.bmp$/,   /\\.gif$/,
              /\\.jpe?g$/, /\\.png$/,
              /\\.tiff$/,  /\\.ico$/,
              /\\.avif$/,  /\\.webp$/,
              /\\.eot$/,   /\\.otf$/,
              /\\.ttf$/,   /\\.woff$/,
              /\\.woff2$/, /\\.svg$/
            ],
            exclude: [ /\\.(js|mjs|jsx|ts|tsx)$/ ],
            type: 'asset/resource',
            generator: { filename: 'static/[hash][ext][query]' }
          },
          // CSS
          {
            test: /\\.(css)$/i,
            use: [
              MiniCssExtractPlugin.loader,
              getCssLoader(),
              getEsbuildCssLoader()
            ]
          },
          // SASS
          {
            test: /\\.(scss|sass)(\\.erb)?$/i,
            use: [
              MiniCssExtractPlugin.loader,
              getCssLoader(),
              getSassLoader()
            ]
          },
          // Esbuild
          getEsbuildRule(),
          // Typescript
          { 
            test: /\\.(ts|tsx|js\\.ts)?(\\.erb)?$/,
            use: [{
              loader: require.resolve('ts-loader'),
            }]
          },
          // Coffee
          { 
            test: /\\.coffee(\\.erb)?$/,
            use: [{ loader: require.resolve('coffee-loader')}]
          },
      ]
    

    Most importantly, this is where I integrated esbuild-loader to speed up my build time drastically. I used esbuild to build javascript files as highlighted by getEsbuildRule() below, then added it to my array of rule objects.

      // webpack/rules.js
    
      const getEsbuildLoader = (options) => {
        return {
          loader: require.resolve('esbuild-loader'),
          options
        }
      }
    
      const getEsbuildRule = () => {
        return {
          test: /\\.(js|jsx|mjs)?(\\.erb)?$/,
          include: [sourcePath, ...additionalPaths].map((path) => resolve(process.cwd(), path)),
          exclude: /node_modules/,
          use: [ getEsbuildLoader({ target: "es2016" }) ]
        }
      }
    
      module.exports = (isTest) => [
          ...,
          getEsbuildRule(),
          ...
      ]
    

    Note: we can also use esbuild to help bundle our CSS files by simply adding esbuild-loader as one of the loaders under the CSS test

      // webpack/rules.js
    
      const getEsbuildCssLoader = () => {
        return getEsbuildLoader({ minify: true })
      }
    
      module.exports = (isTest) => [
          ...
          // CSS
          {
            test: /\\.(css)$/i,
            use: [
              MiniCssExtractPlugin.loader,
              getCssLoader(),
              getEsbuildCssLoader() // <----
            ]
          },
          ...
      ]
    

    The rest are quite self-explanatory. For example, if you have coffeescript files, you need to include coffee-loader and for .sass files you need to include sass-loader.

    We use MiniCssExtractPlugin.loader for both CSS and SASS as highlighted by the official migration guide.

    Then we can import these rules from rules.js file into the base.js file:

      // webpack/base.js
    
      const getRules = require("./rules");
    
      const sharedWebpackConfig = (mode) => {
          ...
    
          return {
              ...,
              module: {
                  strictExportPresence: true,
                  rules: getRules() // <---
              },
              ...
          }
      }
    
  • output

    This is where we tell webpack where it should output our bundles and assets after the bundling process, as well as the filenames that we want. In my case, I wanted webpack to output the finished bundle in public/packs folder so that it can be picked up by Rails inbuilt asset pipeline sprockets (which by default reads the whole public folder)

      // webpack/base.js
    
      // publicRootPath = "public", publicOutputPath = "packs" in our config file
      const { publicRootPath, publicOutputPath } = require("./config")
    
      const sharedWebpackConfig = (mode) => {
          ...
          const hash = isProduction ? "-[contenthash]" : "";
    
          return {
              ...
              output: {
                filename: "[name]-[chunkhash].js",
                chunkFilename: `js/[name]${hash}.chunk.js`,
                hotUpdateChunkFilename: 'js/[id].[fullhash].hot-update.js',
                path: resolve(process.cwd(), `${publicRootPath}/${publicOutputPath}`),
                publicPath: `/${publicOutputPath}/`
              },
              ...
          }
      }
    

    The output.filename I wanted is [name]-[chunkhash].js (for example application1-a28291kskdjfiq.js). Since I turned on code-splitting (chunking) in my optimization settings earlier, I also need to set output.chunkFilename and output.hotUpdateChunkFilename as well. My settings for these variables are different from Webpack’s defaults because of some specific requirements in my application. You are free to follow webpack's defaults if you see fit.

    output.path is where webpack will output our bundled files. In this case, I want the absolute path of public/packs so that webpack can output all the bundled files there.

    output.publicPath is a VERY important setting. This option specifies the public URL of the output directory when referenced in a browser. Our asset pipeline will serve the whole public folder to the browser, so in that sense the root directory (from the POV of the browser) is the inside of thepublic folder. As such, to access our bundled files, we just need to give a relative path of /packs/ to the browser.

  • plugins

    This is where we define 3rd party libraries that can help with our webpack bundling process. Since this is quite a long file, I also moved this into its own file webpack/plugins.js which has this content:

      // webpack/plugins.js
    
      const { sourcePath, devServerPort } = require("./config")
      // To generate manifest.json file
      const WebpackAssetsManifest = require('webpack-assets-manifest');
      // Extracts CSS into .css file
      const MiniCssExtractPlugin = require('mini-css-extract-plugin');
      const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts');
    
      module.exports = (isProduction) => {
        const devServerManifestPublicPath = `http://localhost:${devServerPort}/packs/`;
        const plugins = [
          new WebpackAssetsManifest({
            output: "manifest.json",
            writeToDisk: true,
            publicPath: isProduction ? true : devServerManifestPublicPath,
            entrypoints: true,
            entrypointsUseAssets: true,
          }),
          new RemoveEmptyScriptsPlugin(),
        ]
    
        const hash = isProduction ? '-[contenthash:8]' : ''
        plugins.push(
          new MiniCssExtractPlugin({
            filename: `css/[name]${hash}.css`,
            chunkFilename: `css/[id]${hash}.css`
          })
        )
    
        return plugins;
      }
    

    MiniCssExtractPlugin and RemoveEmptyScriptsPlugin are recommended by the official migration guide. I don't want to output any bundled CSS into public/packs because that is where my bundled javascripts will be outputted at. So, I added the css/ prefix forfilename and chunkFilename settings in MiniCssExtractPlugin to ultimately output these bundled CSS files at public/packs/css.

    Finally, we arrive at the most important plugin which is WebpackAssetsManifest. This is the plugin that generates manifest.json for our browser to read and load all the necessary bundles according to the application that is being loaded.

      // webpack/plugins.js
      ...
    
      module.exports = (isProduction) => {    
          const devServerManifestPublicPath = `http://localhost:${devServerPort}/packs/`;
    
          const plugins = [
              new WebpackAssetsManifest({
                output: "manifest.json",
                writeToDisk: true,
                publicPath: isProduction ? true : devServerManifestPublicPath,
                entrypoints: true,
                entrypointsUseAssets: true,
              }),
              ...
          ]
    
          ...
      }
    

    By default, the output filename will be assets-manifest.json,but for my case I wanted it to be manifest.json (just some legacy behaviour in my codebase). You can choose either one of these filenames.

    We want to always write this manifest file to public/packs (even in development mode) so we set writeToDisk to true. The idea is such that Rails and the browser can always look for this manifest file to load bundled files either from disk or memory (more on this later).

    We set entrypoints and entrypointUseAssets to true so that later our manifest file has the entrypoints key and assets key to separate JS and CSS paths (see structure below)

    The publicPath setting in WebpackAssetsManifest is the PREFIX that will be applied to all of our bundled filenames. This is very important, especially for our dev server setup later.

    In production setting, all the final bundled files will be written into public/packs folder, so we can safely set publicPath to true (this means it will inherit the value of output.publicPath setting that we set earlier).

    As such, in production settings, output.publicPath will be the prefix to all of the bundled filenames, so the prefix will be /packs/. So, the complete bundle filenames would be like these: /packs/bundledjs1.js, /packs/bundledjs2.js, etc.

      // public/packs/manifest.json
    
      {
          ...
          "entrypoints": {
              "application1": {
                  "assets": {
                      "js": [
                          "/packs/bundledjs1.js",
                          "/packs/bundledjs2.js",
                          ...
                      ],
                      "css": [
                          "/packs/css/bundledcss1.css",
                          ...
                      ]
                  }
              },
              "application2": {
                  ...
              },
              ...
          },
          ...
      }
    

    However, in development setting (local server), all the bundled files WILL NOT be written to public/packs folder. In fact, it will not be written anywhere in disk. It will be served purely from memory (in the local server that webpack-dev-server has created).

    Why is that so? Because whenever we make code changes when webpack-dev-server is running, webpack will need to recompile and rebundle all of our files, then generate a new manifest.json file along with new bundled files. Storing and serving them from memory is much better so that we don’t clutter our public/packs folder with new bundled files every time we recompile & rebundle in development mode.

    Since the bundled files are not written to disk, we now have to read the bundled files directly from webpack-dev-server local server instead of public/packs. Remember, however, that manifest.json will still be written to disk because we set writeToDisk: True for our WebpackAssetsManifest plugin.

    So, we want our publicPath value in development mode to be http://localhost:${devServerPort}/packs/ where devServerPort is set as 3035 in our config file. So, in development setting, manifest.json will look something like this:

      // public/packs/manifest.json
    
      {
          ...
          "entrypoints": {
              "application1": {
                  "assets": {
                      "js": [
                          "http://localhost:3035/packs/bundledjs1.js",
                          "http://localhost:3035/packs/bundledjs2.js",
                          ...
                      ],
                      "css": [
                          "http://localhost:3035/packs/css/bundledcss1.css",
                          ...
                      ]
                  }
              },
              "application2": {
                  ...
              },
              ...
          },
          ...
      }
    

And with that, we are done with our webpack/base.js file. Next up are some small tweaks depending on our environment.

Development

Moving on from our base.js file, we wanted to create a specific configuration file for development environment called webpack/development.js . This file will just add-on / modify some settings in our base webpack setting. It has this content, and the comments should be mostly self-explanatory:

// webpack/development.js

const path = require("path");
const { devServerPort, publicRootPath, publicOutputPath } = require("./config");

module.exports = (webpackConfig) => {
  webpackConfig.devtool = "cheap-module-source-map"

  webpackConfig.stats = {
    colors: true,
    entrypoints: false,
    errorDetails: true,
    modules: false,
    moduleTrace: false
  }

  // Add dev server configs
  webpackConfig.devServer = {
    https: false,
    host: 'localhost',
    port: devServerPort,
    hot: false,
    client: {
      overlay: true,
    },
    // Use gzip compression
    compress: true,
    allowedHosts: "all",
    headers: {
      "Access-Control-Allow-Origin": "*"
    },
    static: {
      publicPath: path.resolve(process.cwd(), `${publicRootPath}/${publicOutputPath}`),
      watch: {
        ignored: "**/node_modules/**"
      }
    },
    devMiddleware: {
      publicPath: `/${publicOutputPath}/`
    },
    // Reload upon new webpack build
    liveReload: true,
    historyApiFallback: {
      disableDotRule: true
    }
  }

  return webpackConfig;
}

I changed devtool into cheap-module-source-map and stats to the object above to control what bundle information gets displayed.

Then, I added the devServer config in which I specify lots of settings such as host, port, etc. The most important settings here are:

  • devServer.static.publicPath should be the same as our output.path settings

  • devServer.static.watch should be { ignore: "**/node_modules/**" } to ignore node modules changes (we assume it will be the same)

  • devServer.devMiddleware.publicPath should be the same as our output.publicPath settings so that the dev server knows where to find bundled files relative to browser.

The rest are standard configurations taken from default webpacker settings

Production

Now we want to set our production configs. Create another file to host these production-specific settings at /webpack/production.js. The content is very minimal:

// webpack/production.js

const { EsbuildPlugin } = require('esbuild-loader')
const CompressionPlugin = require('compression-webpack-plugin')

module.exports = (webpackConfig) => {
  webpackConfig.devtool = 'source-map'
  webpackConfig.stats = 'normal'
  webpackConfig.bail = true

  webpackConfig.plugins.push(
    new CompressionPlugin({
      filename: '[path][base].gz[query]',
      algorithm: 'gzip',
      test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/
    })
  )

  const prodOptimization = {
    minimize: true,
    minimizer: [
      new EsbuildPlugin({
        target: 'es2015',
        css: true  // Apply minification to CSS assets
      })
    ]
  }

  Object.assign(webpackConfig.optimization, prodOptimization);

  return webpackConfig;
}

Set bail to true so that webpack fails out on the first error instead of tolerating it. By default, webpack will log these errors in red in the terminal but continue bundling.

We want to add CompressionPlugin to our list of plugins in production settings so that webpack will also output gzip-compressed bundled files for faster load speed later on.

Lastly, we want to add a minimizer in our webpack optimization setting by setting optimization.minimize to true and providing a minimizer in optimization.minimizer. I used EsbuildPlugin as my minimizer, which is much faster than many other minimizers and can simultaneously minify CSS assets too.

Configure Rails

We’re finally done with all the webpack bundling stuff! Now the last thing to do is just to tell Rails how to find the final bundled files. As mentioned above, Rails & the browser will have to read the manifest.json file to figure out which bundled files to load when loading specific entrypoints.

In app/helpers/application_helper.rb, I created these helper functions for our views to fetch the necessary files stated inside manifest.json:

# app/helpers/application_helper.rb

module ApplicationHelper
  def load_webpack_manifest
    JSON.parse(File.read('public/packs/manifest.json'))
  rescue Errno::ENOENT
    fail "The webpack manifest file does not exist." unless Rails.configuration.assets.compile
  end

  def webpack_manifest
    # Always get manifest.json on the fly in development mode
    return load_webpack_manifest if Rails.env.development?

    # Get cached manifest.json if available, else cache the manifest
    Rails.configuration.x.webpack.manifest || Rails.configuration.x.webpack.manifest = load_webpack_manifest
  end

  def webpack_asset_urls(asset_name, asset_type)
    webpack_manifest['entrypoints'][asset_name]['assets'][asset_type]
  end
end

load_webpack_manifest method reads the manifest.json file by looking into the public/packs folder. This method will be utilized by webpack_manifest method below.

webpack_manifest method is used to load the manifest.json file either on the fly in development mode or through the cache in production mode.

In production mode, we will not have any code changes and the build is fixed. Therefore, we can cache the manifest file. If we're reading the manifest file for the first time in production mode, we cache it inside a custom Rails configuration variable (Rails.configuration.x.webpack.manifest). Code courtesy of this blog.

In development mode, we will do lots of changes to our codes and every time we make a code change, webpack will have to rebuild and generate an updated manifest.json and trigger a live reload. So, we cannot just cache manifest.json and use that fixed content because manifest.json can keep on changing as we make code changes. Therefore, we have to keep on reading manifest.json on the fly.

Finally, webpack_asset_urls will list down the locations of all bundled files for a specific entrypoint in manifest.json. We can call this method directly in our views (e.g. application1.html.erb and application2.html.erb in my sample code). Remove all instances of webpacker’s javascript_pack_tag and stylesheet_pack_tag and replace them with the native Rails methods javascript_include_tag and stylesheet_link_tag like so:

<% javascript_include_tag *webpack_asset_urls('application1', 'js'), :defer => true %>
<% stylesheet_link_tag *webpack_asset_urls('application1', 'css'), :defer => true %>

Basically, with javascript_include_tag code above, we will load a %script tag for every URL returned by webpack_asset_urls("ENTRYPOINT_NAME", "js") (we use the iterator shorthand * in front of the function)

For stylesheet_link_tag, we will load a %link{ rel: "stylesheet", href: "path/to/css-asset-url" } for every URL returned by webpack_asset_urls("ENTRYPOINT_NAME", "css")

Conclusion

Andd that's it! Hopefully you get to learn some new stuff through my migration journey from Webpacker to jsbundling-rails + webpack :)

If you have any questions or issues, feel free to open an issue in the repository or leave a comment on this article. Peace!