Skip to content

Instantly share code, notes, and snippets.

Last active Jan 7, 2020
What would you like to do?
Abusing constants table in ClojureScript's compiler

Abusing constants table in ClojureScript's compiler

In React wrapper library UIx that I'm working on there's defui macro that compiles Hiccup directly into React's VirtualDOM. Apart from doing that the macro also hooks into the compiler to hoist constant parts of VirtualDOM across components and namespaces, so that those parts will be essentially interned (cached).

Here's an example of two components defined in different namespaces where both of them share a part of the structure.

(ns foo.core
  (:require [uix.core.alpha :refer [defui]]))

(defui main [class]
  [:main {:class class}
    [:p "Hello!"]])
  (:require [uix.core.alpha :refer [defui]]))

(defui sidebar [class]
  [:aside {:class class}
    [:p "Hello!"]])

When compiled, the macro takes out common parts of Hiccup and replaces them with a symbol that refers to a var that is injected into compiler's environment. Essentially the macro transforms Hiccup into something similar to this (Hiccup to VirtualDOM conversion is skipped for brevity):

[:main {:class class}
[:aside {:class class}

How does this happen? When traversing Hiccup structure the macro detects constant parts of it, analyzes them and registers the value along with produced AST as metadata in compiler's constants table which later is used to emit all of the constant values in a separate shared namespace. Symbols inserted into Hiccup refer to those registered values. See the code in the repo.

What is compiler's constants table? It is a map in compiler's environment under :cljs.analyzer/constant-table key. Compiler environment is a global mutable value that is used to keep track of information about the code during compilation. At the moment ClojureScript's compiler interns symbols and keywords only. The macro extends this mechanism to Hiccup via it own registering function.

(defn register-constant! [env val]
  (let [id (gen-constant-id val)]
    (swap! env/*compiler*
           (fn [cenv]
             (-> cenv
                 (update-in [::ana/constant-table] #(if (get % val) % (assoc % val id)))
                 (update-in [::ana/namespaces (-> env :ns :name) ::ana/constants]
                            (fn [{:keys [seen order] :or {seen #{} order []} :as constants}]
                              (cond-> constants
                                      (not (contains? seen val))
                                      (assoc :seen (conj seen val)
                                             :order (conj order val))))))))

When analyzation phase of the compilation process is done, i.e. all macros were expanded, all constanst were registered, etc. now is the time for compiler to emit AST into a string of JavaScript code.

To make this work UIx hooks into the compiler by extending cljs.compiler/emit-constants-table function, which is responsible for emitting constants. The new part in the function takes care of emitting AST previously put into constants table as metadata.

(do (cljsc/emits "cljs.core." (cljsc/munge value) " = ")
    (-> sym meta :ast cljsc/emits))

Extending the compiler in such a way is not safe, but I think it's interesting to see how we can leverage the compiler in userland for library code. It's also important to understand that hoisted values become global and will never be garbage collected. This impacts both memory consumption and startup time of an application. In this specific case it doesn't add much since UIx transforms hoisted Hiccup into VirtualDOM which is represented by plain JavaScript objects, not Clojure's vectors and hashmaps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment