Problem using malli clojurescript instrumentation and shadow-cljs

I’m trying out the new clojurescript instrumentation in malli 0.8.0 and am getting into issues with hot-reload and shadow-cljs. I’ve battled back-and-forth with this for a while so I’m not quite sure I’ve understood the problem correctly.

Aim: To be able to call malli.instrument.cljs/instrument! from another namespace than the ones where m/=> is called.

Problem: Changed instrumentations do not take effect, not even after quitting and restarting shadow-cljs watch app. I have to delete the .shadow-cljs directory and then restart the watch.

Hypothesized reason: Since the malli.instrument.cljs/instrument! is a macro, it generates the instrumentations at compile time. Since the namespace where it is called is not recompiled when I change the instrumentations (in other namespaces), the changed instrumentations are not included when the macro is expanded. Restarting shadow-cljs watch app doesn’t help since the build is cached.

Potential solution: Can I force shadow-cljs to recompile the namespace where malli.instrument.cljs/instrument! is called whenever any other namespace changes? Or should I go about this some other way?

Instrumentation is kinda tricky since it can affect all namespaces. The best way to go about this is via :preloads with a custom ns created for it.

(ns my.app.preload
  {:dev/always true}
  (:require
    [my.app] ;; must require all namespace here that potentially get instrumented
    [malli.instrument.cljs :as mi]))

(mi/instrument!)

The in your build config :devtools {:preloads [my.app.preload]}.

The {:dev/always true} always metadata on the ns ensures that this namespace is always recompiled. Although note that this can make your build a lot slower. It isn’t strictly necessary if the macro doesn’t emit changing code but since there is no reliable way to know what the macro does (from the shadow-cljs side) it might be best to always run it.

The my.app require ensures that the preload is actually compiled after all your app namespaces have been compiled. I know “preload” isn’t the best name here but you must do this to ensure that other namespaces aren’t still compiling when the macro runs.

Thanks, works! I should of course have tried that but I thought that ^:dev/always only meant that the namespace was reloaded (not recompiled).

Works perfectly in my dev environment (I think). But when I try the exact same setup for my tests (pre-load which requires my core test namespace), I get all kinds of strange errors. Sometimes just some functions are instrumented, and other times mi/instrument! results in a js error claiming that an instrumented function is undefined, although tests against work fine.

Can this be related to how test namespaces are loaded? There is no {:modules {:main {:entries [...]}}] in the test configuration, and the user’s guide suggests that there shouldn’t be one.

EDIT: Tried to enter the core test namespace as main module in :test but that led to a compile error.

These are tricky because the way instrumentation works is that it (set!)s the javascript variable (https://github.com/metosin/malli/blob/2398df55ee806e25592fabf4d0c642ee3a2b233f/src/malli/instrument/cljs.clj#L42)
so depending on the order of how code is evaluated there can be odd behavior.

Are you running tests in a REPL or just as compiled asset?

Thanks @dvingo. Yes, I guess that the order of evaluation in tests is not predictable as it is when you are running a “regular” Shadow-CLJS app. I’m using Shadow-CLJS’ test runner, so compiled and run through the browser (not REPL). I’ve given up on getting instrumentation to work there - I don’t think it is as important though since I’m not running generative tests. It serves a purpose in development though so I get feedback if my functions don’t work in the semi-real world.

If you are able to publish a repository demonstrating the weirdness, I’d like to take a look.

2 Likes

Sure, I’ll see if I can make a repro, but it may take a few weeks (I only develop in my spare time).