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
toGemfile
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 inwebpack.config.js
file--mode
to specify which mode we are in e.g.development
orproduction
--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
, andwebpacker
gem fromGemfile
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 dummyapplication1.js
andapplication2.js
inapp/javascript/packs
to simulate multiple entrypoints.When we were using webpacker, we just need to define
sourcePath
andsourceEntryPath
(which was defined asapp/javascript
andpacks
respectively inwebpacker.yml
) then webpacker will go through every filename inapp/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 ourentry
setting in webpack configconst 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 wholesplitChunks
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 underadditionalPaths
that were specified inwebpacker.yml
. It also addsnode_modules
so that your files can import installed 3rd party libraries. To replicate the same behaviour, I created a simple helper calledgetModulePaths()
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 totrue
... 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 filewebpack/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 usedesbuild
to build javascript files as highlighted bygetEsbuildRule()
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 addingesbuild-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 includesass-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 thebase.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 pipelinesprockets
(which by default reads the wholepublic
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 exampleapplication1-a28291kskdjfiq.js
). Since I turned on code-splitting (chunking) in myoptimization
settings earlier, I also need to setoutput.chunkFilename
andoutput.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 ofpublic/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 wholepublic
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
andRemoveEmptyScriptsPlugin
are recommended by the official migration guide. I don't want to output any bundled CSS intopublic/packs
because that is where my bundled javascripts will be outputted at. So, I added thecss/
prefix forfilename
andchunkFilename
settings inMiniCssExtractPlugin
to ultimately output these bundled CSS files atpublic/packs/css
.Finally, we arrive at the most important plugin which is
WebpackAssetsManifest
. This is the plugin that generatesmanifest.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 bemanifest.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 setwriteToDisk
totrue
. 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
andentrypointUseAssets
totrue
so that later our manifest file has theentrypoints
key andassets
key to separate JS and CSS paths (see structure below)The
publicPath
setting inWebpackAssetsManifest
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 setpublicPath
totrue
(this means it will inherit the value ofoutput.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 thatwebpack-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 newmanifest.json
file along with new bundled files. Storing and serving them from memory is much better so that we don’t clutter ourpublic/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 ofpublic/packs
. Remember, however, thatmanifest.json
will still be written to disk because we setwriteToDisk: True
for ourWebpackAssetsManifest
plugin.So, we want our
publicPath
value in development mode to behttp://localhost:${devServerPort}/packs/
wheredevServerPort
is set as3035
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 ouroutput.path
settingsdevServer.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 ouroutput.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!
Subscribe to my newsletter
Read articles from Wilbert Aristo directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Wilbert Aristo
Wilbert Aristo
Hi! I am a senior software engineer based in sunny Singapore. ☀️ My current work revolves around the full-stack development of loyalty platforms for major banks and financial institutions across the globe at Ascenda. Outside of work, I am a Web3 enthusiast who closely follows developments around cryptocurrency, smart contracts, and NFTs. Oh, and sometimes I DJ for clubs and private events 🎧