Skip to content

Dynamic DOM manipulation aka Template Macros

Tamas Herman edited this page May 19, 2016 · 1 revision

So far we have seen composable HTML structures in defelem and the ability to interact with JS events and HTML attributes through Javelin cells as a unified interface in on! and do!.

What we haven't seen is the ability to add and remove HTML elements from the DOM dynamically (only create them on page load and update their attributes).

The problem: memory leaks

The simplest possible example of adding and removing DOM elements would be alternating between two elements based on the truthiness of a cell value.

(def red?  (cell true))

(cell= (if red? (div :id "red") (span :id "black")))

Note that if we were alternating between two div elements it would be simpler and more efficient to control the :id from a cell: (div :id (cell= red? "red" "black")). In this case however, we also need to alternatively build div and span elements so simply controlling the :id will not be enough.

This will work fine, no problem!

The GC issues come when the clauses create formula cells that refer to cells outside the expression, like this:

(def red?  (cell true))
(def wide? (cell true))

(cell=
  (if red?
    (div :id "red" :css (cell= {:width (if wide? "100px" "50px")}))
    (span :id "black" :css (cell= {:width (if wide? "100px" "50px")}))))

The problem here are the :css (cell= ...) attributes.

  • JavaScript does not provide a weak reference type, so Javelin formulas must contain direct references to the cells they depend on and to the cells that depend on them. This means that formula cells must be disconnected from the dependency graph manually before they will be garbage collected.
  • When an attribute is bound to a Javelin cell, Hoplon uses add-watch to update that attribute on the element whenever the cell updates its value. The watcher callback will close over a reference to the element, thereby preventing the element from being eligible for garbage collection until either the watch is removed (which must be done manually), or the formula cell is garbage collected itself.

So the memory leak is caused by the following:

  • Creating a formula cell that refers to wide?, which is presumably not going to be eligible for GC.
  • Binding that formula cell to the :css attribute of the element which creates a watch that closes over the element.
  • Every time the predicate changes new elements, formula cells, and watches are created, and will not be eligible for GC until wide? is.

The solution: Template Macros

Hoplon provides 5 macros to dynamically control the DOM through Javelin cells modelled on standard clojure flow controls.

  • loop-tpl
  • for-tpl
  • if-tpl
  • case-tpl
  • cond-tpl

Each of these macros enforce two rules:

  1. Only the minimum DOM required to match current cell values will be created
  2. DOM elements are only ever created once. They will be reused if needed, never rebuilt.

For example, we replace our above red/black example with if-tpl, a template macro designed to mirror the native if:

; A
(defc red? true)
(if-tpl red? (div :id "red") (span :id "black"))
; We have `<div id="red"></div>` in the DOM.
; At this point template rule #1 guarantees that the black span
; does not exist anywhere in the DOM or in memory as it is not yet needed.

; B
(reset! red? false)
; We have `<span id="black"></span>` in the DOM.
; The if-tpl keeps `<div id="red"></div>` in memory for reuse later but it is not in the visible DOM.

; C
(reset! red? true)
; We have `<div id="red"></div>` in the DOM once more.
; The div visible in the DOM is the same element that was stored by if-tpl in B.
; The black span has now been moved to memory by the if-tpl for reuse later but it is not in the visible DOM.

; D
(reset! red? false)
; At this point both the visible DOM and in-memory invisible HTML elements are identical to B.
; No additional HTML elements have been created, and never will be, no matter how many times red? is toggled.

The two rules of template macros avoid memory leaks and can dramatically improve performance in complex applications.

Be aware that this approach is not 100% foolproof as the HTML elements stored by the template for reuse are never garbage collected. In theory it is still possible to put pressure on system memory by dynamically creating and storing large DOM structures, but at least the memory usage is bounded.

You can think of the way that the DOM is stored as a "cache" of sorts, but probably quite a bit smarter than most caches you may have worked with in the past. Any Javelin cells in the element definition will still be "live" even if the element is not visible. This means that when the element is finally restored to the visible DOM it will display the most up-to-date values and not the stale HTML from the moment it was originally removed.

for-tpl

Create a list of HTML elements from a CLJS collection.

(defc xs ["a" "b" "c"])
(for-tpl [x xs] (div x))
; <div>a</div><div>b</div><div>c</div>

if-tpl

Create one of two HTML elements based on a predicate.

(defc t "true")
(defc f "false")
(defc ? false)
(if-tpl ? (div t) (span f))
; <span>false</span>

case-tpl

Create one HTML element from a list of options.

(defc route :about)
(case-tpl route
          :home (div "Hello world!")
          :about (div "We are a small team of passionate")
          :contact (div "hello@example.com")
          (div "four oh four"))
; <div>We are a small team of passionate</div>

cond-tpl

Create one HTML element from a list of options using predicates.

(defc random-number 4)
(cond-tpl (cell= (= random-number 4)) (div "totally random!")
          (cell= (= random-number 0)) (div "something went wrong...")
          (cell= (> random-number 4)) (div "balloons")
          :else "bananas")
; <div>totally random!</div>

loop-tpl

Identical to for-tpl but relying on a special :bindings attribute. This is supported in the HTML form of Hoplon (using HTML instead of HLisp) where everything must behave as HTML attributes (keyword/value pairs).

loop-tpl largely exists for backwards compatibility/historical reasons and should not be used for new work. We recommend the use of for-tpl instead as the syntax is simpler and standardised.

(defc xs ["a" "b" "c"])
(loop-tpl :bindings [x xs] (div x))
; <div>a</div><div>b</div><div>c</div>

Template macros must be children of elements

As of this writing, template macros must exist as children of elements. See https://github.com/hoplon/hoplon/wiki/Raw-material#loop-tpl-cannot-be-a-child-of-a-custom-component