Enable tree-shaking in Rails/Webpacker
This article does not cover the basics of what tree-shaking is or how to enable it in Webpack because there is a lot of well-written content out there already. So my intention in this article would be to enable tree-shaking in Webpacker for Rails, revealing its side-effect, and a hack (ugly but works in Production) to circumvent the side-effect.
Tree Shaking
The tree shaking technique is commonly used to eliminate dead/unused code from the generated Javascript bundle to reduce its size in Production. The Webpack documentation nicely explained a way to enable tree-shaking for a single entry file. However, for multiple entries in the real-life scenario (from /packs
folder in the context of Rails/Webpacker), we need to generate an array-like Webpack configuration in ./config/webpack/environment.js
as follows:
const { environment } = require('@rails/webpacker')environment.generateMultiWebpackConfig = function(env) {
// Side-Effect: broken manifest.json file will be generated if
// writeToFileEmit enabled, failing the parsing and Webpack
// compilation randomly.
// Github Issue: https://github.com/rails/webpacker/issues/1251
env.plugins.get('Manifest').opts.writeToFileEmit = false
let webpackConfig = env.toWebpackConfig()
// extract entries to map later in order to generate separate
// webpack configuration for each entry.
// P.S. extremely important step for tree-shaking
let entries = Object.keys(webpackConfig.entry)
// Generate a seed file containing all the entries to write to
// manifest.json when writeToFileEmit is enabled for the last
// entry later on. Without it, only last entry will be written
// down to manifest.json
environment.plugins.get('Manifest').opts.reduce = function(_, file) {
environment.plugins.get('Manifest').opts.seed = Object.assign(
environment.plugins.get('Manifest').opts.seed || {},
{[file.name] : file.path}
)
return environment.plugins.get('Manifest').opts.seed
} // Finally, map over extracted entries to generate a separate
// Webpack configuration for each entry and enable writeToFileEmit
// only for the last entry
return entries.map(function (entryName, i) {
if (i === entries.length - 1) {
env.plugins.get('Manifest').opts.writeToFileEmit = true
webpackConfig = env.toWebpackConfig()
}
return Object.assign(
{},
webpackConfig,
{ entry: { [entryName] : webpackConfig.entry[entryName] } }
)
})
}
module.exports = environment
Please note that the compilation of huge number of JS/TS entries take a lot of time and CPU, hence it is recommended to use this approach only in Production environment. Additionally, set
max_old_space_size
to handle the out-of-memory issue for production compilation:node --max_old_space_size=8000 node_modules/.bin/webpack --config config/webpack/production.js
Then we can import generateMultiWebpackConfig
that we wrote above and export it for Rails/Webpacker to use in ./config/webpacker/production.js
.
const environment = require('./environment')
module.exports = environment.generateMultiWebpackConfig(environment)
Side Effect
This setup, however, will still generate an invalid manifest.json
, notice the extra }
ending brace in the middle.
{
"b.js": "/packs/b-b8a5b1d3c0c842052d48.js",
"b.js.map": "/packs/b-b8a5b1d3c0c842052d48.js.map"
} "a.js": "/packs/a-a3ea1bc1eb2b3544520a.js",
"a.js.map": "/packs/a-a3ea1bc1eb2b3544520a.js.map"
}
But since Webpacker is not reading the JSON file (and only writing for the last entry file), the Webpack compilation will not halt fortunately.
The Hack
The hack may not be necessary if you do not want to read the generated manifest.json
for some reason. But we do rely on it to inline Javascript during the deployment phase by mapping the source paths with output paths given in the manifest file.
So let’s fix the broken manifest.json
in ./config/webpack/fix_manifest.js
where we read the generated manifest file and remove the extraneous }
.
const fs = require('fs');
fs.readFile('./public/packs/manifest.json', 'utf8', function (err, data) {
try {
JSON.parse(data);
} catch (e) {
// Replace the first instance of `}` with `,`
var corruptJSON = JSON.stringify(data);
var validJSON = corruptJSON.replace('}', ',');
fs.writeFile('./public/packs/manifest.json', JSON.parse(validJSON), function(err) {
if (err) console.log(err);
});
}
});
Add it to the NPM scripts in order to run it via npm run fix_manifest
in Dockerfile after rails assets pre-compilation finishes.
"scripts": {
"fix_manifest": "node ./config/webpack/fix_manifest.js"
}
That’s all for now. ️🧘♂️