Using devcards with shadow-cljs

I decided to learn to use Devcards. Devcards is easy to set up with Figwheel, but my current setup for clojurescript uses shadow-cljs.

This is not terribly difficult once you know how, but I decided it was worth collating the snippets of info needed to make this work. 

The following is in fairly exhaustive detail, and includes a few wrong turns including error messages, so as to hopefully be a useful resource for when things go wrong, as well as when things go right!

Step One - Create new project

We'll start by creating a new shadow-cljs project, using the quick start instructions. I'll use the same filenames and directory structure used in those instructions, to make it easier to follow. We should therefore end up with a shadow-cljs.edn file that looks like this:

;; shadow-cljs configuration
{:source-paths
 ["src/dev"
  "src/main"
  "src/test"]

 :dependencies
 []
 
 :dev-http {8082 "public"}

 :builds
 {:frontend
  {:target :browser
   :output-dir "public/js"
   :asset-path "/js"
   :modules {  :main {:init-fn acme.frontend.app/init}}}}}

I've added the :dev-http {8082 "public"} in order to control which port the dev http server will start on. I've also included the :output-dir and :asset-path keys and values - although the values shown are the default values, I think it's easier to figure out later what needs changing for future projects if you have them listed explicitly. 

Our directory structure now looks like this:

$ tree .

.
├── node_modules
│   └── asn1.js
    └── ...
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── shadow-cljs.edn
└── src
    ├── main
    │   └── acme
    │       └── frontend
    │           └── app.cljs
    └── test

Note that ... in the diagram means "other files or directories listed that were removed from the output when pasting here, to make it easier to see the more important files"

Our single namespace is the cljs file src/main/acme/frontend/app.cljs and should look like this:

(ns acme.frontend.app)

(defn init []
  (println "Hello World"))

The Figwheel docs describe some of the next few steps - I have incorporated them below along with some explanation and any setup specific to shadow-cljs. 

Step Two - Edit the clojurescript build options, add :compiler-options {:devcards true} to the map corresponding to the :frontend key in shadow-cljs.edn file.

In shadow-cljs, this is done in the shadow-cljs.edn file, as explained in the docs:

Most of the standard ClojureScript Compiler Options are either enabled by default or do not apply. So very few of them actually have an effect. A lot of them are also specific to certain :target types and do not apply universally (e.g. :compiler-options {:output-wrapper true} is only relevant for :target :browser).

We also need to add our dependencies. 

Our shadow-clsj.edn should then look like this:

{:source-paths   ["src/dev"
                  "src/main"
                  "src/test"]

 :dependencies    [[devcards "0.2.6"]
                   [reagent "1.0.0-alpha2"]]

 :dev-http {8082 "public"}

 :builds    {:frontend
               { :target :browser
                 :output-dir "public/js"
                 :asset-path "/js"
                 :modules 
                     {:main 
                       {:init-fn acme.frontend.app/init}}
                 :compiler-options {:devcards :true}}}}

The first dependency is the devcards library, the second is reagent, which we will use to generate react components. 

See my previous post if you need more details on setting up the shadow-cljs.ednconfig.

Step Three - require devcards.core in any files (namespaces) that use devcards

E.g

(ns acme.frontend.app
  (:require
   [devcards.core :as dc :refer [defcard]))

Step Four - include code to start the Devcards UI in the entry point function we defined as :init-fn in shadow-cljs.edn

(defn init []
  (dc/start-devcard-ui!))

Step Five - Installing CLJSJS dependencies...via npm

If we enter the terminal at the root of our acme project, and try and compile our program, we get an error:

$ npx shadow-cljs watch :frontend

shadow-cljs - config: /Users/myname/acme-app/shadow-cljs.edn
shadow-cljs - updating dependencies
shadow-cljs - dependencies updated
running: npm install --save --save-exact react@16.13.0 react-dom@16.13.0
+ react@16.13.0
+ react-dom@16.13.0
added 7 packages from 2 contributors and audited 106 packages in 1.158s

1 package is looking for funding
  run `npm fund` for details

found 0 vulnerabilities

shadow-cljs - HTTP server available at http://localhost:8082
shadow-cljs - server version: 2.9.8 running at http://localhost:9630
shadow-cljs - nREPL server started on port 57191
shadow-cljs - watching build :frontend
[:frontend] Configuring build.
[:frontend] Compiling ...
[:frontend] Build failure:
The required JS dependency "marked" is not available, it was required by "cljsjs/marked.cljs".

Dependency Trace:
    acme/frontend/app.cljs
    devcards/core.cljs
    devcards/util/markdown.cljs
    cljsjs/marked.cljs

Searched for npm packages in:
    /Users/ghufran/Documents/programming/clojure/clojurescript/acme-app/node_modules

See: https://shadow-cljs.github.io/docs/UsersGuide.html#npm-install

Although it may seem like the terminal is frozen, shadow-cljs is still running in the terminal window, so we can open another terminal window for the next step - once we have done so, shadow-cljs should automatically pick up the change and re-compile our program

We can see that shadow-cljs has automatically installed react and react-dom using npm. However, The required JS dependency "marked" is not available, it was required by "cljsjs/marked.cljs".

The problem is that devcards is relying on cljsjs to provide javascript libraries (like marked), whereas shadow-cljs accesses npm packages directly. This is because shadow-cljs 'knows' to use npm to fulfil the cljsjs dependencies - but we still need to install those npm dependencies. We could keep doing this iteratively, adding each npm dependency based on the error messages we get each time, but to save time, I have included the two npm dependencies it turns out that need at this stage (marked and create-react-class) to be installed in a single command below:

$ npm install  marked create-react-class
+ create-react-class@15.6.3
+ marked@1.1.0
added 14 packages from 87 contributors and audited 120 packages in 1.457s

1 package is looking for funding
  run `npm fund` for details

found 0 vulnerabilities

In our original terminal window, we should now see our program build successfully.

shadow-cljs - config: /Users/myname/acme/shadow-cljs.edn
shadow-cljs - HTTP server available at http://localhost:8082
shadow-cljs - server version: 2.9.8 running at http://localhost:9630
shadow-cljs - nREPL server started on port 63451
shadow-cljs - watching build :frontend
[:frontend] Configuring build.
[:frontend] Compiling ...
[:frontend] Build completed. (166 files, 0 compiled, 0 warnings, 3.65s)

If we open a browser window at the address listed for the HTTP server (http://localhost:8082 in this case), we see...nothing. We need to open the developer tools, one of which is the console. When we do that, we see some errors in the console:

An error occurred when loading devcards.system.js
env.evalLoad @ main.js:2231
(anonymous) @ main.js:2395
main.js:2232 ReferenceError: React is not defined
    at eval (system.cljs:321)
    at eval (system.cljs:321)
    at eval (<anonymous>)
    at Object.goog.globalEval (main.js:836)
    at Object.env.evalLoad (main.js:2229)
    at main.js:2395
env.evalLoad @ main.js:2232
(anonymous) @ main.js:2395
main.js:2231 An error occurred when loading devcards.core.js
env.evalLoad @ main.js:2231
(anonymous) @ main.js:2406
main.js:2232 ReferenceError: React is not defined
    at eval (core.cljs:117)
    at eval (core.cljs:117)
    at eval (<anonymous>)
    at Object.goog.globalEval (main.js:836)
    at Object.env.evalLoad (main.js:2229)
    at main.js:2406
env.evalLoad @ main.js:2232
(anonymous) @ main.js:2406
app.cljs:6 acme.frontend.app is running
shadow.module.main.append.js:4 An error occurred when calling (acme.frontend.app/init)
eval @ shadow.module.main.append.js:4
goog.globalEval @ main.js:836
env.evalLoad @ main.js:2229
(anonymous) @ main.js:2408
main.js:2231 An error occurred when loading shadow.module.main.append.js
env.evalLoad @ main.js:2231
(anonymous) @ main.js:2408
main.js:2232 TypeError: devcards.system.start_ui is not a function
    at Function.eval [as cljs$core$IFn$_invoke$arity$1] (core.cljs:71)
    at Function.eval [as cljs$core$IFn$_invoke$arity$0] (core.cljs:64)
    at Object.acme$frontend$app$init [as init] (app.cljs:7)
    at eval (shadow.module.main.append.js:4)
    at eval (<anonymous>)
    at Object.goog.globalEval (main.js:836)
    at Object.env.evalLoad (main.js:2229)
    at main.js:2408
env.evalLoad @ main.js:2232
(anonymous) @ main.js:2408
browser.cljs:20  shadow-cljs: WebSocket connected!
browser.cljs:20  shadow-cljs: load JS clojure/walk.cljs
browser.cljs:20  shadow-cljs: load JS cljs/spec/gen/alpha.cljs
browser.cljs:20  shadow-cljs: load JS cljs/spec/alpha.cljs
browser.cljs:20  shadow-cljs: load JS cljs/repl.cljs
browser.cljs:20  shadow-cljs: load JS cljs/user.cljs
browser.cljs:20  shadow-cljs: REPL session start successful

The console.log statement in our init function ran, as we can see the acme.frontend.app is running statement in the console. However, there was also an error: ReferenceError: React is not defined. Now that we have the shadow-cljsjs library included in our shadow-cljs.edn dependencies, as well as the reagent library, which should include react, this implies that while the library is in our app, we may not have required it correctly in our namespace. Similarly, the main.js:2232 TypeError: devcards.system.start_ui is not a function likely has the same cause. 

Remember, devcards is expecting cljsjs to fulfil it's dependencies, so we put the cljsjs versions of those two javascript libraries, [cljsjs.react] and [cljsjs.react.dom] in the (:require ... ) statement.

(ns acme.frontend.app
  (:require [devcards.core :as dc :refer [defcard]
            [cljsjs.react]
            [cljsjs.react.dom]))

(defn init []
  (js/console.log "acme.frontend.app is running")
  (devcards.core/start-devcard-ui!))

And...we still get the same error. What's going on here? It turns out that we need to make sure the order of the :require vectors is the same as the order that they will be required. I've never come across this in clojure, I suspect it may be something specific to the way shadow-cljs handles cljsjs dependencies. Anyway, we can fix it by changing the require statement like this:

(ns acme.frontend.app
  (:require
   [cljsjs.react]
   [cljsjs.react.dom]
   [devcards.core :as dc :refer [defcard]))


(defn init []
  (js/console.log "acme.frontend.app is running")
  (dc/start-devcard-ui!))

Now when we go back to the browser, everything seems to load without any problems:

acme.frontend.app is running
shadow-cljs: WebSocket connected!
shadow-cljs: load JS clojure/walk.cljs
shadow-cljs: load JS cljs/spec/gen/alpha.cljs
shadow-cljs: load JS cljs/spec/alpha.cljs
shadow-cljs: load JS cljs/repl.cljs
shadow-cljs: load JS cljs/user.cljs
shadow-cljs: REPL session start successful

So we can add some examples from the reagent-specific parts of the devcards docs. However, you should note that the format of the namespace declaration given in the devcards docs doesn't work in shadow-cljs at present, probably because of the cljsjs loading issue mentioned earlier:

;; from http://rigsomelight.com/devcards/#!/devdemos.reagent

(ns xxx
    (:require [devcards.core]
              [reagent.core :as reagent])
    (:require-macros [devcards.core :as dc
                                    :refer [defcard defcard-rg]]))

So this will again give errors, in this case, ReferenceError: devcards is not defined. Instead, use the format below:

(ns acme.frontend.app
  (:require
   [cljsjs.react]
   [cljsjs.react.dom]
   [reagent.core :as r]
   [devcards.core :as dc :refer [defcard defcard-rg]))


(defn init []
  (js/console.log "acme.frontend.app is running")
  (dc/start-devcard-ui!))


;; examples from http://rigsomelight.com/devcards/#!/devdemos.reagent


(defcard reagent-example-1
  (r/as-element [:h1 "Reagent Example"]))


(defn on-click [ratom]
  (swap! ratom update-in [:count] inc))

(defonce counter1-state (r/atom {:count 0}))

(defn counter1 []
  [:div "Current count: " (@counter1-state :count)
   [:div
    [:button {:on-click #(on-click counter1-state)}
    "Increment"]]])

(defcard-rg counter1
   [counter1])

You should now see the devcards user interface, as shown here:

Click on the link for acme.frontend.app, and you should see the devcards we defined in our acme.frontend.app namespace: