Ok, this is for the CLJS enthusiasts trying to get your builds as small as possible. The Closure Compiler is quite good at eliminating dead code from your builds. However, it sometimes leaves some Zombie code that is essentially dead, but not quite. This stems from the fact that :advanced dead-code elimination isn’t quite aware of some CLJS code patterns and doesn’t identify them as dead. The lines get a bit blurry sometimes, so this is hard to get right to begin with.

A common example is any code using defmethod/defmulti. Any defmethod modifies the object created by defmulti. So, to the closure compiler this looks like it is getting used. Identifying whether the defmulti is ever actually called is a bit tricky, so this all stays alive forever as possible Zombies lingering in your code. A common offender here is cljs.pprint, often added during development, forgotten and just waiting to add hefty chunk of dead weight to your builds.

Identify your Zombies via Build Reports

Unfortunately, actual dead code can be rather hard to identify from just the compiler side. So, sometimes a little help is needed to identify and remove it.

You’ll have to generate a build report and dig into it a little bit. Only you know what is actually needed.

Here is an example build report from a dummy build. Using an example of a perfectly valid namespace that just wanted to colocate some tests with the code directly in the same file.

(ns demo.zombie
  (:require
    [cljs.test :refer (deftest is)]))

(defn init []
  ;; just some code to keep the CLJS collections alive
  (js/console.log [:hello {:who "World!"}]))

(deftest my-test
  (is (= :super :cool)))

The resulting report looks like this:

In this particular case the deftest macro already takes care of eliding the test from the build. What however cannot be elided like this is the (:require [cljs.test ...]) from the ns form. cljs.test alone isn’t that large, so it doesn’t hurt that much. It however brings in cljs.pprint, which adds a quite hefty chunk.

Next I’ll cover some strategies to deal with this, but first to get an actual comparison here is the build we actually want.

We removed ~30kb gzip’d from our build, which in this instance is pretty significant. Of course in larger builds it won’t be that dramatic, but as I said this is for enthusiasts that care about the little things.

Strategy #1: Avoid it in the first place

Using my test example above the common recommended strategy is to just have the tests in their own dedicated namespace. Moving the test to demo.zombie-tests will also allow removing the cljs.test require from the demo.zombie namespace. Thus avoiding the problem entirely.

Sometimes you still want to modify your runtime environment in a way that makes development easier. My recommendation here is to use the :preloads option in your build config. This lets you inject extra code only during development, that is not added to your release build.

Strategy #2: Stub it out

Moving tests to a separate file is of course subjective and many people prefer to keep tests colocated with the actual source. Other times the code causing the issue is code used added only for development purposes. cljs.pprint alone is a prime example. It just makes it easier to just quickly pprint something if it is already required and ready to go.

So, instead of being forced to remove a required namespace, we can just replace that namespace with a stubbed out shell that never has the problematic code in the first place. re-frame-10x has been doing that for a long time, but I never quite documented how that all works.

The :ns-aliases build option in shadow-cljs lets you define replacements that should be used whenever a :require for a certain ns is encountered. For example :build-options {:ns-aliases {cljs.pprint cljs.pprint-stubs}} causes any (:require [cljs.pprint ...]) to act as if you had (:require [cljs.pprint-stubs ...]) in your code instead. It’ll never actually add cljs.pprint to your build. You can set this as a :release option in your build config, so that it still stays as normal during development.

As of shadow-cljs version 3.0.5 I added a new :release-stubs option to make this a bit less verbose. It expects a set of namespace symbols to stub out. For the build above I added :release-stubs #{cljs.test} to the build config, and shadow-cljs automatically sets that as the proper ns alias, by using the convention of adding -stubs and using those as the replacement. In this release I also added 2 basic stubs to shadow-cljs itself, so that cljs.test and cljs.pprint are already covered and don’t need to be recreated in every project. The files can be found here.

Any library can follow this pattern and just provide the stubs directly and letting users opt-in to use them.

Strategy #3: Stubbing the old way

I’d consider this a deprecated and undesirable option, but I will cover it since many people are used to it from other tools. :ns-aliases lets you swap out namespace by using a new name. Another option is to replace the actual name.

The way this is done is by swapping the definitions on the classpath directly. So, you’d have a dev/my/app.cljs and a prod/my/app.cljs, both defining (ns my.app), giving you a namespace that is defined twice. One with all the development stuff, one a bit more “optimized”. The classpath is then used to only include one at a time.

shadow-cljs itself doesn’t support this and cannot switch the classpath between builds or dev/release modes. You can however do this directly with deps.edn and just launching shadow-cljs from there.

{:paths ...
 :deps ...
 :aliases
 {:dev {:extra-paths ["dev"] ...}
  :prod {:extra-paths ["prod"] ...}
  :shadow {:extra-deps {thheller/shadow-cljs ...}}}

Then when making a release build activate the :prod alias and tell shadow-cljs to make a release build.

clj -M:shadow:prod -m shadow.cljs.devtools.cli release app

This of course works fine, but I never quite liked the classpath switching since it requires launching a new JVM to make release builds. I prefer to just have one setup that lets me seamlessly switch between watch/release without even restarting shadow-cljs.