CLJS: Dealing with Zombies
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.