The Many Ways to use shadow-cljs

This post on ClojureVerse prompted a whole discussion about boot vs lein again and all I can think is: Why does it matter?

Are we going to add the tools.depsclojure tool to this discussion next?

Why not just use Clojure? You can get very far with just doing that and as a bonus you can do everything from the REPL as well. I’m going to use shadow-cljs as an example here but I think it applies to a whole lot of other “tools” as well.

Build it as a Library first

First of foremost shadow-cljs is built as a normal Clojure Library. You can add the thheller/shadow-cljs artifact to any tool that is able to construct a Java Classpath for you and you can start using it.

The .cli namespace is just a small wrapper to process the strings we get from the command line and turn them into proper Clojure data. In the REPL you can just call the .api namespace directly (properly require‘d of course)

Why have a command line tool then?

Convenience

It can also check a lot of things without an actual JVM and can provide faster feedback in those cases.

Optimization

Starting a JVM+Clojure+Deps takes a while. This can be improved if the Clojure code is AOT compiled but it still won’t be very fast. Fortunately we don’t need to start a new JVM for everything, we can just re-use one we already started.

This is exactly what the shadow-cljs tool does. It will AOT compile the relevant code on the first startup so subsequent startups are faster. The shadow-cljs server starts the JVM in server mode. Every other command will then use that JVM instead of starting a new one.

This concept is not new. grench and drip come to mind or any Clojure REPL.

Here are some numbers to compare the effect this optimization has.

The command used is:

touch src/starter/browser.cljs && time shadow-cljs compile app

AOT

Server

Cache

Time

0m25.730s

✓

0m14.575s

✓

✓

0m7.815s

-

✓

0m7.706s

-

✓

✓

0m0.673s

touch to force a recompile when using incremental compilation

Server means shadow-cljs server is running, no new JVM is started

As you can see the difference is quite dramatic. Given that I get easily distracted when waiting for things this has a huge impact on my focus during the day.

The non-Server code could be optimized a bit since it always loads all development related code. compile for example doesn’t need all the REPL/live-reload related code but given the presence of server this never seemed necessary.

You’ll most likey use watch during actual development and all tools have an optimized experience for this but it still matters for other commands.

But what about boot?

boot tries to do a lot more than just providing a classpath. The problem with this is that it only works if you want to use the exact abstractions boot provides. As soon as you want to do something slightly different it just starts getting in the way. I don’t recommend using shadow-cljs with boot since it breaks all the caching shadow-cljs tries to do. boot-cljs has the same problem as far as I can tell. Restarting the boot process wipes all cache. You could make an argument here that this is a good thing since it prevents stale cache but that just treats the symptom instead of fixing the root cause (which I did in shadow-cljs).

If you separated the classpath/pod management from boot it would make for a very good library I think. I do like some ideas in boot but its too complected for me.

Conclusion

Write everything as a Clojure Library so it works in any tool and the REPL.

I simplified a great deal here. Things are a lot more complicated in the real world but I am convinced that we can get way better results if less code was written specific to one build tool and instead used Clojure as the common ground.