Shadow CLJS + Webpack

In this post, I’ll share how I’ve gotten shadow-cljs working alongside Webpack using the :esm target. This allows for Webpack’s tree-shaking of JavaScript dependencies while still having all the benefits of shadow-cljs.

For Vistaly.com (a product-strategy startup I’m co-founding), we were using large JavaScript libraries (such as AWS Amplify) and because of this, we had a very large bundle size.

Before the switch, our bundle size was 11MB uncompressed 🐢. CloudFront only compresses files under 10MB (source), which meant our users had to wait to download the full 11MB before their app would load.

By switching to ESM + Webpack, our bundle size decreased to 3.7MB uncompressed / 0.81MB compressed (via Brotli compression). To end-users, there was a 93% decrease in the quantity of data that had to be downloaded 🐇.

There were minimal changes needed for this (it was mostly trial and error to get the pieces working well together). First I updated shadow-cljs.edn. By specifying :js-provider :import, I’m having shadow-cljs leave the bundling of JS dependencies to Webpack.

;; shadow-cljs.edn
{:builds
 {:web
  {:devtools {:watch-dir "resources/public"}
   :output-dir "build"
   :target :esm
   :js-options {:js-provider :import}
   :modules {:main {:init-fn com.vistaly.web.core/init!}}}}}

Secondly, I created a webpack.config.js file. Moving forward, the local running server will be Webpack’s dev server. Hot reloading will still be done by shadow-cljs. I also added source-map-loader so that you can view ClojureScript source-maps from within your browser.

const path = require("path");
const directory = path.resolve(__dirname, "resources/public");
const { DEV_MODE } = process.env;

module.exports = {
  devtool: DEV_MODE && "eval-cheap-module-source-map",
  devServer: {
    client: {
      overlay: {
        errors: true,
        warnings: false,
      },
    },
    historyApiFallback: true,
    hot: false,
    liveReload: false, // rely on shadow's reloading
    static: { directory },
    port: 9000,
  },
  entry: "./build/main.js",
  mode: DEV_MODE ? "development" : "production",
  module: DEV_MODE && {
    rules: [
      {
        test: /\.js$/,
        enforce: "pre",
        include: [path.resolve(__dirname, "build")],
        use: ["source-map-loader"],
      },
    ],
  },
  output: {
    filename: "./js/main.js",
    path: directory,
  },
};

Lastly, I had to update our babashka scripts to reflect the changes.

The dev command changed to utilize npx webpack serve along with DEV_MODE=true, while the release command changed to utilize npx webpack.

Hope that helped! if anything wasn’t clear or you’re interested in Vistaly, you can find me on the Clojurians Slack channel (@dehli). Talk soon! 👋