Why HoloMap is built the way it is, and what that costs you.
The problem
A map library needs three hooks from its framework:
- Mount: build a
maplibregl.Maponce the container is in the DOM. - Update: push every prop change into that long-lived object.
- Unmount: call
map.remove()and release the WebGL context.
Hologram 0.11 offers one and a half. init/2 and init/3 run once per
component instance, and put_action/2 can schedule client-side work from there
which covers mount. There is no "props changed" hook and no unmount hook. And
JavaScript interop runs only inside action handlers: it is a no-op during
server-side rendering and inside init itself.
An imperative design would therefore need every component to fire an action on every render just to check whether anything changed. That is both impossible (there is no such hook) and wrong (it would fight the virtual DOM).
The approach
HoloMap does not drive MapLibre from Elixir call sites at all. Every child
component renders one hidden element carrying its MapLibre specification as
JSON, and a reconciler in the browser watches those elements:
<HoloMap.Layer.Fill id="parcels-fill" paint={%{fill_color: @colour}} />
│
│ Hologram renders, and re-renders, this
▼
<div data-hm="layer"
data-hm-key="parcels-fill"
data-hm-map="explorer"
data-hm-spec='{"type":"fill","paint":{"fill-color":"#3b82f6"}}'
data-hm-events='{"click":{"action":"parcel_clicked","target":"page","params":{}}}'>
│
│ MutationObserver sees the attribute change
▼
map.setPaintProperty("parcels-fill", "fill-color", "#ef4444")Mount, update and unmount all fall out of that:
| Event | What the reconciler does |
|---|---|
| An element appears | Add the source / layer / marker / control |
data-hm-spec changes | Apply the narrowest MapLibre call that covers the difference |
data-hm-events changes | Rebind listeners |
| An element disappears | Remove the corresponding object |
| The container goes stale | map.remove() |
Crucially, this works with Hologram's virtual DOM rather than against it. Hologram already preserves JavaScript-managed children of an element that stays in the template, which is exactly what MapLibre's canvas is.
Where the pieces live
lib/holo_map/
├── json.ex A JSON encoder that survives compilation to JavaScript
├── spec.ex Props → MapLibre specification keys
├── event.ex Event-handler props → dispatch descriptors
├── def.ex The shared body of every definition component
├── map.ex The container; the only stateful component
├── source/ What data comes from
├── layer/ How it looks
├── marker.ex DOM overlays
├── popup.ex
├── control/ MapLibre's built-in controls
├── terrain.ex Map-level 3D properties
├── sky.ex
├── api.ex The imperative escape hatch
├── runtime.ex The only module that touches JavaScript
└── js/runtime.mjs The reconcilerThe shape is deliberate: everything except runtime.ex and runtime.mjs is
pure Elixir that builds JSON. Since Hologram's interop cannot be reached from
ExUnit, keeping the JavaScript surface to four functions is what makes the rest
of the library testable at all.
The DOM protocol
| Attribute | Meaning |
|---|---|
data-hm | Kind: map, source, layer, marker, popup, control, terrain, sky |
data-hm-key | Identity. Stable across re-renders; changing it is a remove plus an add |
data-hm-map | The cid of the owning map |
data-hm-spec | The MapLibre specification, JSON-encoded |
data-hm-events | Event name → dispatch descriptor, JSON-encoded |
Definitions render inside #hm--<cid>--defs, a hidden sibling of the canvas
container. MapLibre owns #hm--<cid>; Hologram owns the defs. Keeping them
apart means MapLibre's DOM and Hologram's DOM never contend for the same node.
Readiness
MapLibre will not accept addSource or addLayer before it has parsed a style,
and a page renders its whole component tree before the first byte of style
arrives. So style-scoped work (sources, layers, terrain, sky) is queued and
flushed on style.load.
Not on load. MapLibre's load event also waits for the first complete render,
which never arrives if a tile request hangs; gating on it produced a map that
rendered its background and stayed permanently empty.
Markers, popups and controls wait for nothing. They attach to the map object, not to the style, and queueing them would mean controls that never appear when a tile server is slow.
Update costs
Not every prop change costs the same. In rough order:
| Change | Cost |
|---|---|
| A paint or layout property | setPaintProperty / setLayoutProperty. Free |
| A layer filter | setFilter. Free |
| A layer zoom range | setLayerZoomRange. Free |
A GeoJSON source's data | setData. Free; no layer is disturbed |
An image source's url or coordinates | updateImage. Free |
The map's center / zoom / bearing / pitch | jumpTo. Free |
A layer's type, source or source_layer | Remove and re-add the layer |
| Any other source key | Detach the dependent layers, rebuild the source, re-add them |
The map's style | Everything is discarded and re-declared on the next style.load |
| A control's props | Remove and re-add the control |
The practical guidance: put frequently-changing data in a GeoJSON source's
data prop and frequently-changing appearance in paint. Both are free.
Two consequences of DOM morphing
Hologram does not tear the old page's DOM down and build the new one on
client-side navigation. It morphs one into the other, patching attributes in
place. Any library reconciling DOM state against a long-lived JavaScript object
has to account for that, and HoloMap learned both the hard way.
Definitions must be matched by ownership, not containment. During a
navigation the outgoing map's definition elements briefly carry the incoming
page's values. A reconciler trusting containment reads that as prop changes on a
live map, moving its camera and dispatching the resulting moveend through
Hologram.dispatchAction, which is deferred and therefore lands on whichever
page is mounted by then. That surfaced as FunctionClauseError in the
destination page's action/3. Hence data-hm-map, and hence camera moves the
reconciler initiates carrying an event marker so they are never reported back.
Staleness is not isConnected. The outgoing container is patched into the
incoming one rather than removed, so it never stops being connected to the
document. Disposal keyed on isConnected alone leaked one WebGL context per
navigation, against a browser limit of roughly sixteen. Staleness is decided by
the container's id as well.
Testing
Two suites, because the library has two halves:
mix testcovers everything that builds JSON. Component tests render through Hologram's own renderer and assert on the attributes each component emits.demo/test/browser/drives a real MapLibre instance through Playwright and asserts on what MapLibre actually built, throughgetLayer,getPaintPropertyandgetFilter, reached viawindow.HoloMap.map/1.
The split is forced: Hologram's JS interop is a no-op on the server, so no
amount of ExUnit reaches runtime.mjs. Both navigation bugs above were found by
the browser suite and are pinned there.