This file contains the concatenated content of all cards to help LLMs understand the available documentation.
@3sln/dodo/style is an optional module. It renders a subtree into a shadow
root with constructable stylesheets adopted, so its CSS is genuinely scoped:
rules inside cannot leak out, and page rules cannot leak in.
This is the one thing the platform does properly and no naming convention can match. It needs no reactive API — nothing here re-renders on its own.
cssA tagged template producing a CSSStyleSheet:
import {css, scoped} from '@3sln/dodo/style';
const styles = css`
p { color: rebeccapurple; margin: 0; }
:host { display: block; }
`;
Memoised per call site. A tagged template hands the same frozen strings array
to every evaluation of that literal, so the cache is keyed on it: a css inside
a render function returns the identical sheet each render unless an interpolated
value actually changed. Without that, every render would build a fresh sheet,
and reassigning adoptedStyleSheets would force a style recalculation each
time.
Interpolation treats only null and undefined as empty — ${0} is a value,
not a blank.
css.in(view) builds in another realm, for an iframe or a test environment
whose DOM is not installed globally.
scoped(props?, ...children)scoped({styleSheets: [styles]},
p('scoped, and unreachable from the page'),
)
The children are reconciled into the shadow root, and updated in place on later renders like any other subtree. The stylesheet list is only reassigned when it actually changes — including when it becomes empty, which is how you remove one.
import * as d from '@3sln/dodo';
import {css, scoped} from '@3sln/dodo/style';
import {fromObservable, watch} from '@3sln/dodo/reactive';
// A page level rule that would hit any unscoped paragraph.
const pageStyles = `
.style-demo p { color: crimson; text-decoration: underline; }
`;
export default driver => {
driver.panel('Demo', (container, signal) => {
const color$ = driver.property('Scoped colour', {defaultValue: '#2f7d32', type: 'color'});
const color = fromObservable(color$, {initial: '#2f7d32'});
d.reconcile(container, [
d.style(pageStyles),
d
.div(
d.p('Unscoped: the page rule reaches this one.'),
// Rebuilt per render, but `css` is memoised per call site, so the
// sheet is only actually rebuilt when the interpolated colour changes.
watch(color, value =>
scoped(
{
styleSheets: [
css`
p {
color: ${value};
font-weight: bold;
}
`,
],
},
d.p('Scoped: the page rule cannot reach in, and this rule cannot leak out.'),
),
),
)
.classes('style-demo'),
]);
signal.addEventListener('abort', () => {
d.reconcile(container, null);
});
});
};
A shadow root cannot be removed once attached, so detaching tears its contents
down instead: $detach hooks fire, listeners come off, and the adopted
stylesheets are dropped. This is worth stating because it is easy to get wrong —
leaving the shadow content in place looks harmless and quietly leaks every
listener under it.
reconcileShadow(host, children, styleSheets?) mounts a dodo tree into a shadow
root you own rather than one scoped created, and detachShadow(host) reverses
it:
import {reconcileShadow, detachShadow} from '@3sln/dodo/style';
class MyWidget extends HTMLElement {
connectedCallback() {
reconcileShadow(this, [p('hello')], [styles]);
}
disconnectedCallback() {
detachShadow(this);
}
}
Shadow roots are a real boundary for context too.
withContext crosses them; withEncapsulatedContext stops at them. Pair
scoped with withEncapsulatedContext when a component's internals should stay
genuinely internal.
import style from '@3sln/dodo/src/style.js';
const {scoped} = style({dodo: myDodo});
reconcile functionThe reconcile function is the heart of Dodo. It's responsible for applying your virtual DOM (VNode) changes to the actual DOM. It has a few distinct modes of operation.
reconcile(target, [vnodes...]) (Into)When you pass an array of VNodes as the second argument, Dodo renders them into the target element, managing its children.
reconcile(target, vnode) (Onto)When you pass a single VNode, Dodo renders it onto the target element, taking control of the element itself. This has some important rules:
h() or a helper), its tag must match the target element's tag.alias or special component, it can be rendered onto any element. It effectively takes over that element.reconcile(target, null | []) (Cleanup)reconcile(target, []) clears all children from a Dodo-managed element.reconcile(target, null) detaches Dodo entirely from the target element and all its descendants, running all necessary cleanup logic.import * as d from '@3sln/dodo';
export default driver => {
const {button, div, p, h, alias} = d;
const myAlias = alias(text => p(`(This is an alias) ${text}`).style({color: 'blue'}));
driver.panel('Demo', (container, signal) => {
// 1. Create the DOM elements we want to target manually.
const divTarget = document.createElement('div');
divTarget.style.border = '2px solid #ccc';
divTarget.style.padding = '1em';
divTarget.style.minHeight = '50px';
divTarget.style.marginTop = '0.5em';
const spanTarget = document.createElement('span');
spanTarget.style.border = '2px solid #ccc';
spanTarget.style.padding = '1em';
spanTarget.style.minHeight = '50px';
spanTarget.style.marginTop = '0.5em';
spanTarget.style.display = 'block';
const controls = div(
button('reconcile(div, [p]) (Into)').on({
click: () => d.reconcile(divTarget, [p('Rendered into the div.')]),
}),
button("reconcile(div, h('div')) (Onto)").on({
click: () =>
d.reconcile(divTarget, div('Rendered onto the div.').style({backgroundColor: '#aeffae'})),
}),
button('reconcile(div, myAlias) (Onto)').on({
click: () => d.reconcile(divTarget, myAlias('Rendered onto the div.')),
}),
button("reconcile(span, h('div')) (Error)").on({
click: () => {
try {
d.reconcile(spanTarget, h('div', 'fails'));
} catch (e) {
alert(e.message);
}
},
}),
button('reconcile(span, myAlias) (Onto)').on({
click: () => d.reconcile(spanTarget, myAlias('Rendered onto the span.')),
}),
button('Clear All').on({
click: () => {
d.reconcile(divTarget, []);
d.reconcile(spanTarget, []);
},
}),
).style({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5em',
paddingBottom: '1em',
borderBottom: '1px solid #eee',
});
// 2. Create opaque placeholder VNodes and use $attach to append the manual elements.
const demoArea = div(
p('Target DIV:'),
div()
.opaque()
.on({$attach: el => el.appendChild(divTarget)}),
p('Target SPAN:').style({marginTop: '1.5em'}),
div()
.opaque()
.on({$attach: el => el.appendChild(spanTarget)}),
);
d.reconcile(container, [controls, demoArea]);
signal.addEventListener('abort', () => {
d.reconcile(container, []);
});
});
};
@3sln/dodo/reactive is an optional module. Dodo's core does not import it,
does not know it exists, and works exactly the same whether or not you use it.
Bring your own reactivity if you already have one — this module is here so that
you do not have to, and so that whatever you do bring plugs in cleanly.
Everything in this module is built on one small interface:
{
onDirty(fn) -> unsubscribe, // fn is called when the value may have changed
getValue() -> any // the current value
}
That is the whole contract. It is deliberately push to invalidate, pull to read: a source announces that something changed, and dodo decides when to actually read it. That split is what lets a burst of updates collapse into a single render.
Dodo does not depend on the observable protocol, on signals, or on any other library. It depends on this. Adapters bridge the rest.
Two values carry special meaning:
PENDING — no value yet. watch renders its placeholder instead of calling
your builder.getValue(). watch catches it and
renders its error view.cell(initialValue)A writable cell. Enough on its own for most applications.
import {cell} from '@3sln/dodo/reactive';
const count = cell(0);
count.getValue(); // 0
count.setValue(5);
count.update(n => n + 1);
derive(dependencies, compute)A read-only cell computed from other cells (plain values are allowed too). The
result is memoised while something is subscribed, so compute runs once per
upstream change rather than once per read. If any dependency is PENDING the
derived cell is PENDING and compute is not called at all.
const price = cell(10);
const qty = cell(3);
const total = derive([price, qty], (p, q) => p * q);
mapCell(source, fn) is the single dependency shorthand, and constant(value)
is a cell that never changes.
watchimport {cell, watch} from '@3sln/dodo/reactive';
import {reconcile, p} from '@3sln/dodo';
const name = cell('world');
reconcile(container, [watch(name, value => p(`hello ${value}`))]);
name.setValue('dodo'); // re-renders on the next frame
watch(source, builder, options?) takes any cell — or a plain value, which it
renders once. Options:
| option | meaning |
|---|---|
placeholder |
() => vnode rendered while the source is PENDING |
error |
(error) => vnode rendered when getValue or builder throws |
Renders are scheduled through dodo's scheduler, so several changes in the same
tick produce one render. A change that leaves the value equal (by the instance's
shouldUpdate) does not re-render at all.
import * as d from '@3sln/dodo';
import {cell, derive, fromObservable, watch} from '@3sln/dodo/reactive';
export default driver => {
driver.panel('Demo', (container, signal) => {
// A plain writable cell, driven by the buttons below.
const qty = cell(1);
// The deck driver hands out observables, which adapt to the Cell protocol.
const price$ = driver.property('Price', {defaultValue: '10'});
const price = fromObservable(price$, {initial: '10'});
// Derived cells recompute only when a dependency actually changes.
const total = derive([price, qty], (p, n) => (Number(p) || 0) * n);
const button = (label, onClick) =>
d
.button(label)
.style({'margin-right': '0.5em', padding: '0.25em 0.75em'})
.on({click: onClick});
d.reconcile(container, [
d.div(
d.p(
button('-', () => qty.update(n => Math.max(0, n - 1))),
button('+', () => qty.update(n => n + 1)),
watch(qty, n => d.span(`quantity: ${n}`)),
),
watch(total, value => d.p(`total: ${value.toFixed(2)}`).style({'font-weight': 'bold'})),
),
]);
signal.addEventListener('abort', () => {
d.reconcile(container, null);
});
});
};
The protocol is small enough that most libraries adapt in a few lines. Three
adapters cover the usual shapes, and each one connects to its source lazily —
on the first listener — and disconnects on the last, so a detached watch
never leaves a subscription behind.
fromObservable(observable, {initial})For subscribe({next, error, complete}) sources: RxJS, the TC39 proposal,
bones' Observable.
import {fromObservable, watch} from '@3sln/dodo/reactive';
const user = fromObservable(user$);
watch(user, u => p(u.name), {placeholder: () => p('loading…')});
fromSubscribe(subscribable, {initial})For subscribe(value => ...) sources returning an unsubscribe function: Svelte
stores, and most hand-rolled stores.
fromSignal(signal, {effect})For preact signals. Reads go through signal.peek(), so consuming a signal as a
cell never registers a tracking dependency on some unrelated effect that happens
to be running. Invalidation uses signal.subscribe when it exists; pass the
library's own effect function if you would rather drive it that way.
import {signal} from '@preact/signals-core';
import {fromSignal, watch} from '@3sln/dodo/reactive';
const count = signal(0);
watch(fromSignal(count), n => p(String(n)));
If your source does not fit, write the object literal. That is all a cell is:
function fromMediaQuery(query) {
const list = window.matchMedia(query);
return {
onDirty(fn) {
list.addEventListener('change', fn);
return () => list.removeEventListener('change', fn);
},
getValue: () => list.matches,
};
}
notifier() is exported for cases where you need to fan out to several
listeners yourself.
toObservable(cell) exposes a cell to code that expects subscribe.effect(cell, fn) runs fn with the value now and on every change, returning
a dispose function. Use it for work that is not rendering — persistence,
logging, imperative DOM.readCell(x) reads a cell and passes plain values straight through.isCell(x) duck-types the protocol.The bindings exported from @3sln/dodo/reactive are built against dodo's
default instance. If you build your own dodo with dodo(userSettings), or you
want to change how renders are scheduled, bake your own copy with the same
factory pattern the rest of the project uses:
import reactive from '@3sln/dodo/src/reactive.js';
import context from '@3sln/dodo/src/context.js';
const userSettings = {dodo: myDodo};
const reactiveApi = reactive(userSettings);
const {withContext, useContext} = context({...userSettings, reactive: reactiveApi});
Build the reactive API once and inject it where it is needed. Every call to
reactive produces a new watch, and a special component's identity is its
descriptor object — the reconciler uses that identity to decide whether a DOM
node can be reused, so two separately built watch components would never reuse
each other's nodes.
context requires the injection rather than falling back to a private copy,
precisely so that mistake cannot happen quietly.
| setting | default |
|---|---|
dodo |
required |
schedule |
the instance's schedule |
renderError |
a <pre> with the message and stack |
Map handling and change detection are not settings here — they always come from the dodo instance, since a module that disagreed with its renderer about what a map is would be worse than useless.
Cells themselves are instance-independent. Only watch needs to know which dodo
it is rendering into.
By default watch defers renders through the dodo instance's schedule, so a
burst of changes in one tick collapses into a single render on the next frame.
Replace schedule to change that:
// Render synchronously — handy in tests.
reactive({dodo, schedule: fn => fn()});
// Render when the browser is idle.
reactive({dodo, schedule: (fn, {signal} = {}) => {
const id = requestIdleCallback(() => fn());
signal?.addEventListener('abort', () => cancelIdleCallback(id));
}});
A schedule implementation receives (fn, {signal}) and should not run fn
once signal has aborted — that is how watch cancels a pending render when it
is detached.
The whole scheduler is replaceable at the dodo level too, which the modules then inherit:
import {dodo} from '@3sln/dodo';
const myDodo = dodo({scheduler: {schedule, flush, clear}});
@3sln/dodo/observe is an optional module, built on
the reactive module. It turns ResizeObserver and
IntersectionObserver into Cells, so element measurements compose with
everything else reactive.
It comes in two layers, and you can use either.
elementSize, elementVisibility and elementIntersection are plain functions
of an element. They have no dependency on dodo at all — read them, test them,
feed them into derive, ignore the components entirely.
import {elementSize} from '@3sln/dodo/observe';
const size = elementSize(myElement);
size.getValue(); // {width, height}, measured now
const stop = size.onDirty(render);
Cleanup is structural: the observer is connected on the first listener and
disconnected on the last. There is no teardown step to forget, and a detached
watch takes its observer with it.
elementSize(element, options?)Reports a plain {width, height}.
Plain on purpose. Dodo's change detection treats any object that is neither an
array nor a plain object as always changed, so handing back a
DOMRectReadOnly would re-render on every observer callback. A plain object is
shallow compared, so a resize that does not change the box renders nothing.
Never PENDING — with no observation to hand it measures directly, so there is
no "not known yet" case to write code for.
| option | meaning |
|---|---|
box |
content-box (default), border-box, device-pixel-content-box |
window |
override the realm the observer is taken from |
elementVisibility(element, options?)Reports a boolean, and is PENDING until the observer's first entry arrives.
That entry is asynchronous and there is no synchronous way to know beforehand.
Reporting false would be a guess, and a wrong guess shows the wrong branch for
a frame — so the honest answer is PENDING, paired with watch's
placeholder. Pass {initial: false} if you would rather take the guess.
| option | meaning |
|---|---|
root |
a selector resolved with closest, an element, or null |
rootMargin |
passed through to IntersectionObserver |
threshold |
passed through to IntersectionObserver |
initial |
value before the first entry, PENDING by default |
window |
override the realm the observer is taken from |
elementIntersection is the same but reports {visible, ratio}.
import {withElementSize, withVisibility} from '@3sln/dodo/observe';
withElementSize(size => canvas().props({width: size.width, height: size.height}))
withVisibility(visible => (visible ? chart() : skeleton()), {
root: '.scroller',
rootMargin: '200px',
placeholder: () => skeleton(),
})
Each is a thin wrapper: build the cell from the component's own DOM node, then
render watch(cell, builder). Options are shallow compared, so an inline
options literal does not tear down and rebuild the observer on every render;
changing an option does rebuild it.
placeholder and error pass straight through to watch.
import * as d from '@3sln/dodo';
import {withElementSize, withVisibility} from '@3sln/dodo/observe';
const box = (label, ...children) =>
d.div(d.strong(label), ...children).style({
border: '1px solid #ccc',
'border-radius': '4px',
padding: '0.75em',
'margin-bottom': '0.75em',
});
// The size reported is the container's, not the wrapper's: the component's own
// node is display:contents, so it measures the nearest laid out ancestor.
const measured = () =>
box(
'Container size',
withElementSize(size => d.p(`${Math.round(size.width)} x ${Math.round(size.height)} px`)),
);
// Scroll the inner panel to bring the tracked block in and out of view.
const tracked = () =>
box(
'Visibility',
d
.div(
d.div(d.p('Scroll down…')).style({height: '10em'}),
withVisibility(
visible =>
d.p(visible ? 'on screen' : 'off screen').style({color: visible ? 'green' : '#999'}),
{root: '.observe-scroller', placeholder: () => d.p('…').style({color: '#999'})},
),
d.div().style({height: '10em'}),
)
.style({
height: '8em',
overflow: 'auto',
border: '1px solid #ccc',
'border-radius': '4px',
padding: '0.5em',
})
.classes('observe-scroller'),
);
export default driver => {
driver.panel('Demo', (container, signal) => {
const width$ = driver.property('Panel width %', {defaultValue: '60'});
const sub = width$.subscribe(width => {
d.reconcile(container, [
d.div(measured(), tracked()).style({width: `${Number(width) || 60}%`, 'min-width': '12em'}),
]);
});
signal.addEventListener('abort', () => {
sub.unsubscribe();
d.reconcile(container, null);
});
});
};
This is the part worth understanding, because the two components deliberately differ.
Dodo gives every alias and special wrapper display: contents, and neither
observer reports anything useful for an element with no box.
withElementSize walks up to the nearest ancestor that is laid out. You
are almost always asking "how much room do I have?", and the answer comes from
the container, not from a wrapper with no geometry. nearestLaidOutElement is
exported if you need the same walk yourself; it crosses shadow boundaries.withVisibility gives its own node a box — display: block by default —
and observes that. Here you are asking "is this content on screen", and an
ancestor's visibility would be the wrong answer for, say, one row of a long
list. Pass a different display if block disturbs your layout, or null to
walk up like withElementSize does.Content whose size depends on its container's size will oscillate. Renders are
deferred through the scheduler, so you will not see the browser's
ResizeObserver loop limit exceeded error — which means the loop shows up as a
smooth endless animation instead of an exception, and is harder to spot. If a
withElementSize subtree never settles, look for a size that feeds back into
itself.
Constructors are taken from the element's own realm — ownerDocument.defaultView
— not from globals. An element inside an iframe is observed by that iframe's
implementation, and a test environment whose DOM is not installed globally works
without extra wiring.
Where a constructor is genuinely missing, the cell throws when it connects,
which watch turns into its error view rather than an exploded render. Server
rendering a tree containing these components will hit that: give them an error
builder, or keep them out of the server-rendered path.
Same shape as the other modules — the reactive API is a required injection:
import reactive from '@3sln/dodo/src/reactive.js';
import observe from '@3sln/dodo/src/observe.js';
const userSettings = {dodo: myDodo};
const {withElementSize, withVisibility} = observe({
...userSettings,
reactive: reactive(userSettings),
});
Dodo is a minimal, highly configurable virtual DOM library. It is not a framework. Instead, it provides the core tools to efficiently create, update, and manage DOM elements based on a virtual representation, giving you full control over your application's rendering process and data model.
Everything in Dodo revolves around a few key functions:
h(tag, ...children): The core function for creating a virtual element node. Properties, styling, classes, attributes and dataset entries are chained onto the result (.props(), .style(), .classes(), .attrs(), .data()).alias(fn): A wrapper for creating memoized, pure-function components.special(config): A powerful tool for creating components with lifecycle hooks (attach, update, detach), perfect for integrating with third-party libraries or browser APIs.For convenience, Dodo also provides helper functions that look like HTML tags (div, p, h1, etc.). You build a tree of these virtual nodes and then use the reconcile(domNode, vdomTree) function to make the real DOM match your virtual tree.
import * as d from '@3sln/dodo';
export default driver => {
const name$ = driver.property('Name', {defaultValue: 'World'});
driver.panel('Demo', (container, signal) => {
const sub = name$.subscribe(name => {
d.reconcile(container, [
d.div(d.h1(`Hello, ${name}! `), d.p('This is a simple demo for the Dodo library.')),
]);
});
signal.addEventListener('abort', () => {
sub.unsubscribe();
d.reconcile(container, []);
});
});
};
Dodo's real power lies in its configurability. The default export is a pre-configured instance that works with standard JavaScript objects and arrays.
However, by using the dodo(settings) factory, you can replace the underlying data structure handlers. This allows Dodo to work seamlessly with other programming paradigms or languages—like ClojureScript—that use different data structures.
See the Customization card for a live example of this in action.
Dodo ships five modules that its core neither imports nor knows about. Ignore them entirely and Dodo behaves exactly the same.
@3sln/dodo/reactive — reactive rendering built on a two method Cell
protocol, with adapters for observables and signals. See the
Reactivity card.@3sln/dodo/context — DOM-scoped context for passing data down the tree.
See the Context card.@3sln/dodo/observe — element size and visibility as Cells. See the
Observing Elements card.@3sln/dodo/animate — enter and exit animation. See the
Enter and Exit Animation card.@3sln/dodo/style — shadow DOM scoped CSS. See the
Scoped Styling card.The first four build on the Cell protocol; style stands alone.
Dodo provides two primary ways to create element VNodes: the low-level h() function and a set of convenient HTML helper functions.
h(tag, ...children) functionThe h() function is the core of element creation.
tag (string): The HTML tag name (e.g., 'div', 'p')....children: A list of child VNodes, strings, or numbers.Everything else about an element — its properties, styling, classes, attributes and dataset — is chained onto the vnode it returns.
import * as d from '@3sln/dodo';
const vnode = d.h('div',
d.h('p', 'Hello, World!').classes('greeting')
).props({ id: 'my-div' });
For convenience, Dodo exports helper functions for all standard HTML tags. These are simply wrappers around the h() function.
import * as d from '@3sln/dodo';
const vnode = d.div(
d.p('Hello, World!').classes('greeting')
).props({ id: 'my-div' });
The void elements — img, input, link, meta, area, track, embed,
param, source and col — take no arguments at all, since they can have no
children:
d.img().props({ src: '/logo.png', alt: 'Logo' })
Every setter returns the vnode, so they compose in any order:
d.div('content')
.props({ id: 'card' })
.style({ 'background-color': 'white', padding: '1em' })
.classes('card', isActive && 'active')
.attrs({ role: 'group', 'aria-label': 'Card' })
.data({ cardId: id })
.key(id)
.on({ click: onClick });
| setter | applies to |
|---|---|
.props(map) |
properties written straight onto the element (id, value, …) |
.style(map) |
inline styles, via style.setProperty — names are CSS-cased |
.classes(...) |
class names; nested lists are flattened, blanks are skipped |
.attrs(map) |
attributes, via setAttribute |
.data(map) |
dataset entries |
.key(k) |
the identity used when reconciling a list |
.on(map) |
event listeners and the $attach / $update / $detach hooks |
.opaque() |
marks the element's children as none of Dodo's business |
Every setter replaces rather than merges: calling one twice leaves the second
value. All but .key() and .on() apply to element nodes only.
Because each map is compared in its own right rather than by identity, an object
literal rebuilt on every render — .props({id}), .style({color}) — compares
equal when its contents have not changed, and costs no reconciliation pass.
Children are values: VNodes, strings, numbers, and lists of them at any depth.
null, undefined and false render nothing, while 0 and '' are real text.
An object with no text form of its own is refused rather than rendered as
[object Object] — most often it is a map that belongs in .props() or
.style() and ended up in the child list. An object that can describe itself,
such as a Date, still renders.
[!NOTE] Props used to be an optional first argument, and styling, classes, attributes and dataset used to be
$-prefixed props. Both forms are gone; see MIGRATION.md.
import * as d from '@3sln/dodo';
export default driver => {
driver.panel('Demo', (container, signal) => {
const vnode = d
.div(
d.h2('Generated by Dodo'),
d.p('This structure was created using the HTML helper functions.'),
d.p('The equivalent using h() would be:'),
d.pre(
d.code(
`d.h('div', null,
d.h('h2', 'Generated by Dodo'),
d.h('p', 'This structure was created using the HTML helper functions.'),
).style({ border: '1px solid #ccc', padding: '1em' });`,
),
),
)
.style({border: '1px solid #ccc', padding: '1em'});
d.reconcile(container, [vnode]);
signal.addEventListener('abort', () => {
d.reconcile(container, []);
});
});
};
Dodo is designed to be configurable. You can create your own instance of the Dodo API by calling the dodo factory with a settings object. This is particularly useful when integrating with other languages or frameworks that have their own data structures, like ClojureScript's persistent maps and vectors.
This demo shows how to configure Dodo to work with ClojureScript. The demo runs the compiled JavaScript output, but displays the original .cljs source code in the "Source" panel, thanks to the canonical-src attribute.
(ns custom-dodo
(:require ["@3sln/dodo" :as dodo-core]))
(def dodo-settings
#js {
:shouldUpdate (fn [a b] (not= a b))
:isMap (fn [x] (or (map? x) (and (object? x) (= (.-constructor x) js/Object))))
:mapIter (fn [m] (if (map? m)
(es6-iterator (map #(into-array %) (seq m)))
(js/Object.entries m)))
:mapGet (fn [m k]
(if (map? m)
(if (string? k)
(or (get m k) (get m (keyword k)))
(get m k))
(aget m k)))
:mapMerge (fn [& maps] (apply merge maps))
:newMap (fn [obj] (if (map? obj) obj (js->clj obj :keywordize-keys true)))
:mapPut (fn [m k v] (if (map? m) (assoc m k v) (do (aset m k v) m)))
:isSeq (fn [x] (or (and (seqable? x) (not (string? x))) (js/Array.isArray x)))
:seqIter (fn [s] (if (seqable? s) (es6-iterator s) s))
:convertName name
:listenerKey :listener
:captureKey :capture
:passiveKey :passive
})
(def d (dodo-core/dodo dodo-settings))
(def my-component (d/alias (fn [text]
(d/div
(d/h1 "Hello from ClojureScript!")
(d/p "The text is: " text)))))
(defn ^:export default [driver]
(let [text$ (.property driver "Text" #js {:defaultValue "dynamic text"})]
(.panel driver "Demo"
(fn [container signal]
(let [sub (.subscribe text$ (fn [text]
(d/reconcile container [(my-component text)])))]
(.addEventListener signal "abort" #(.unsubscribe sub)))))))
Dodo needs to read a map, and to walk its entries. Reading is mapGet. Walking
comes in two shapes, and you may supply either:
| setting | shape |
|---|---|
mapIter(map) |
returns an iterable or iterator of [name, value] pairs |
mapEach(map, visit, a, b, c) |
calls visit(name, value, a, b, c) for each entry |
mapIter is the simpler one and is enough. mapEach exists because
reconciling a single element walks its props, styling, attrs, dataset and
hooks — and building an array of pairs for each of those, on every update, is
the largest single source of garbage in a render.
mapEach never materialises the pairs, so a custom collection can be exactly as
cheap as a plain object. ClojureScript has reduce-kv, Immutable.js has
forEach, and most persistent structures have something similar:
dodo({
isMap: x => x instanceof MyMap,
mapGet: (m, k) => m.get(k),
mapEach: (m, visit, a, b, c) => m.forEach((v, k) => visit(k, v, a, b, c)),
// ...
});
The a/b/c slots carry the reconciler's context through to its visitor, so
that iterating does not allocate a closure either. Pass them straight through
and otherwise ignore them.
Supply both and mapEach is used. Supply neither and dodo assumes plain
objects — which is also what isMap, mapGet and the rest default to, so a
custom collection was always a matter of describing it fully.
[!NOTE] If you override
isMapandmapGetfor a custom collection, you must also overridemapIterormapEach. Without one of them dodo will walk your maps as if they were plain objects and find nothing.
@3sln/dodo/context is an optional module, built on top of
the reactive module. It passes data down the tree without
threading it through every intermediate component.
Context here is scoped to the DOM, not to a module-level registry: a provider stashes its data on its own DOM node, and a consumer walks up the DOM to find it. Two independent widgets on the same page never see each other's context, and nothing is global.
withContext(data, ...children)Provides data to everything rendered beneath it.
import {withContext, useContext} from '@3sln/dodo/context';
import {reconcile, div, p} from '@3sln/dodo';
reconcile(container, [
withContext({theme: 'dark'},
div(
useContext(['theme'], ({theme}) => p(`theme is ${theme}`)),
),
),
]);
useContext(keys, builder)Consumes context. keys names the entries you care about; builder receives a
map of just those entries and returns a vnode.
Naming your keys is not bookkeeping — it is what makes the consumer cheap. A provider updating a key you did not ask for produces an equal selection, and the re-render is skipped entirely.
Providers nest, and the merged view is what a consumer sees. The nearest provider wins for any given key:
withContext({theme: 'dark', locale: 'en'},
withContext({theme: 'light'},
// sees {theme: 'light', locale: 'en'}
useContext(['theme', 'locale'], render),
),
)
Updating any provider above a consumer — not just the closest one — updates that consumer.
Shadow roots are a real boundary, and you choose which side of it your data belongs on:
withContext crosses shadow roots. Use it for genuinely ambient
application state: theme, locale, the current user.withEncapsulatedContext stops at the shadow root it lives in. Use it for
a component's own internals, so that a consumer inside some unrelated
component's shadow tree cannot accidentally pick it up.Both kinds merge together for a consumer that can see them, ordered by depth.
import * as d from '@3sln/dodo';
import {fromObservable, watch} from '@3sln/dodo/reactive';
import {useContext, withContext} from '@3sln/dodo/context';
export default driver => {
driver.panel('Demo', (container, signal) => {
const color$ = driver.property('Color', {defaultValue: '#7b2ff7', type: 'color'});
const label$ = driver.property('Label', {defaultValue: 'ambient'});
// Two levels of nesting between the provider and the consumers: neither the
// section nor the paragraph passes anything down.
const card = () =>
d
.section(
d.p('Nothing on this path passes props. The values come from context.'),
useContext(['color', 'label'], ({color, label}) =>
d.p(`consumed: ${label}`).style({color, 'font-weight': 'bold'}),
),
// A consumer that asks only for `color` is not re-rendered when
// `label` changes.
useContext(['color'], ({color}) =>
d.div().style({'background-color': color, height: '2em', 'border-radius': '4px'}),
),
)
.style({border: '1px solid #ccc', padding: '1em', 'border-radius': '4px'});
const state = fromObservable(
{
subscribe(observer) {
let color, label;
const emit = () => observer.next?.({color, label});
const a = color$.subscribe(v => {
color = v;
emit();
});
const b = label$.subscribe(v => {
label = v;
emit();
});
return {
unsubscribe() {
a.unsubscribe();
b.unsubscribe();
},
};
},
},
{initial: {color: '#7b2ff7', label: 'ambient'}},
);
d.reconcile(container, [watch(state, data => withContext(data, card()))]);
signal.addEventListener('abort', () => {
d.reconcile(container, null);
});
});
};
The components are the interesting part, but the underlying pieces are exported for when you are integrating with something that is not dodo:
| function | purpose |
|---|---|
attachContext(node, data, encapsulated?) |
makes node a provider |
updateContext(node, data, encapsulated?) |
replaces a provider's data |
detachContext(node, encapsulated?) |
removes a provider |
readContext(node, keys) |
reads the context visible from node, once |
contextCell(node, keys) |
the same, as a Cell you can watch |
As with the reactive module, the exports of @3sln/dodo/context are built
against the default dodo instance. Bake your own if you use a custom one.
Consumers render through a watch, so context requires the reactive API as an
injected dependency. Pass the one your application already uses:
import reactive from '@3sln/dodo/src/reactive.js';
import context from '@3sln/dodo/src/context.js';
const userSettings = {dodo: myDodo};
const {withContext, useContext} = context({
...userSettings,
reactive: reactive(userSettings),
});
It is required rather than optional on purpose. A private fallback would work,
but its watch would be a different component from the application's, and the
reconciler treats a special's descriptor as the node's identity — so the two
would silently never reuse each other's DOM nodes. Better to state the
dependency than to satisfy it quietly.
watch is not re-exported from this module; import it from
Reactivity, along with the
other settings this shares — dodo, schedule and renderError.
A consumer resolves its provider chain when it renders and whenever it is updated. Physically relocating a mounted consumer to a different part of the tree — without re-rendering it — will not re-resolve the chain on its own.
Dodo provides two types of components for creating reusable and stateful logic: alias and special.
alias(renderFn)An alias is a lightweight, stateless component. It's a function that takes arguments and returns a VNode. Use it to break down your UI into smaller, reusable pieces.
import * as d from '@3sln/dodo';
export default driver => {
const coloredBox = d.alias(props => {
const {color, count} = props;
return d.div(d.h3('Aliased Component'), d.p(`Render count: ${count}`)).style({
'background-color': color,
padding: '1em',
color: 'white',
borderRadius: '4px',
textAlign: 'center',
});
});
driver.panel('Demo', (container, signal) => {
const color$ = driver.property('Color', {defaultValue: '#007aff', type: 'color'});
let renderCount = 0;
const sub = color$.subscribe(color => {
renderCount++;
d.reconcile(container, [coloredBox({color, count: renderCount})]);
});
signal.addEventListener('abort', () => {
sub.unsubscribe();
d.reconcile(container, []);
});
});
};
alias vs. Plain FunctionsWhile a plain JavaScript function that returns a VNode can work, alias provides key optimizations and capabilities:
alias component is memoized. It only re-renders if its arguments change. A plain function is re-executed on every render of its parent.alias is backed by a stable DOM element (<udom-alias>). This allows you to chain methods like .key() and .on() to the component itself, which is not possible if a function returns a raw array of VNodes.import * as d from '@3sln/dodo';
// A plain function - will re-run every time its parent renders.
const plainGreeting = (name, color) =>
d.h1(`Hello, ${name}!`).style({ color });
// An alias - will only re-render if `name` or `color` changes.
const aliasedGreeting = d.alias((name, color) =>
d.h1(`Hello, ${name}!`).style({ color })
);
// Because it has a stable node, you can do this:
const keyedGreeting = aliasedGreeting('World', 'blue').key('greeting-1');
special(config)A special component is for advanced use cases that require state, lifecycle hooks, and direct access to the underlying DOM element. The config object can have attach, update, and detach methods.
attach(element): Called when the component's host element is first created and attached to the DOM.update(element, newArgs, oldArgs): Called on initial render and whenever the component's arguments change.detach(element): Called when the component is removed from the DOM.Important: A
specialcomponent is fully responsible for managing its own children and performing cleanup. If you used.reconcile()inside theupdatehook to render children, you must calld.reconcile(element, [])inside thedetachhook to clean them up.
import * as d from '@3sln/dodo';
export default driver => {
const specialCounter = d.special({
attach(element) {
console.log('specialCounter attached!');
element.counter = 0;
element.style.border = '1px solid green';
element.style.padding = '1em';
},
update(element, [label, visible]) {
if (!visible) {
d.reconcile(element, [d.p('Component detached.')]);
return;
}
element.counter++;
d.reconcile(element, [
d.h2(`${label}: ${element.counter}`),
d.p(
'This component has its own internal state (',
element.counter,
') and lifecycle hooks.',
),
]);
},
detach(element) {
console.log('specialCounter detached! Counter was:', element.counter);
// A special component is responsible for its own cleanup.
d.reconcile(element, []);
},
});
driver.panel('Demo', (container, signal) => {
const show$ = driver.property('Visible', {defaultValue: true, type: 'checkbox'});
const sub = show$.subscribe(visible => {
d.reconcile(container, [specialCounter('Counter', visible).key('test')]);
});
signal.addEventListener('abort', () => {
sub.unsubscribe();
d.reconcile(container, []);
});
});
};
@3sln/dodo/animate is an optional module, built on
the reactive module. It holds an element on screen long enough
to animate out, and reports which phase of appearing or disappearing it is in.
withPresence(isPresent, builder, config?)builder receives the current phase:
despawned → spawning → spawned → despawning → despawned
import {withPresence} from '@3sln/dodo/animate';
withPresence(isOpen, phase => dialog({open: phase !== 'despawned'}, body()), {
spawn: {styling: {opacity: '1'}},
despawn: {styling: {opacity: '0'}},
mode: 'remove',
})
The component also mirrors its phase onto its own node as data-presence, which
is the hook to write CSS against — the node is created by the reconciler, so
there is otherwise no selector for it:
[data-presence] { opacity: 0; transition: opacity 400ms ease; }
That base rule is the state the element animates from. The spawn spec's styles are applied one frame later and become the state it rests at.
| config | meaning |
|---|---|
spawn |
the animation spec for entering |
despawn |
the animation spec for leaving |
mode |
'remove' renders nothing once despawned |
display |
the display the node is given while present, block by default |
An element that starts absent starts hidden, without animating. An element that starts present animates in — that is usually the point.
import * as d from '@3sln/dodo';
import {withPresence} from '@3sln/dodo/animate';
import {cell, watch} from '@3sln/dodo/reactive';
// The presence node mirrors its phase onto itself as `data-presence`, which is
// the hook to style. The base rule is the "from" state; the spawn spec applies
// the "to" state one frame later, so the transition has something to run from.
const styles = `
[data-presence] {
opacity: 0;
transform: translateY(-10px);
transition: opacity 400ms ease, transform 400ms ease;
}
.demo-card {
padding: 1em;
border: 1px solid #ccc;
border-radius: 4px;
}
`;
const config = {
mode: 'remove',
spawn: {styling: {opacity: '1', transform: 'translateY(0)'}},
despawn: {styling: {opacity: '0', transform: 'translateY(-10px)'}},
};
export default driver => {
driver.panel('Demo', (container, signal) => {
const shown = cell(true);
d.reconcile(container, [
d.style(styles),
d.p(
d.button('Toggle').on({click: () => shown.update(v => !v)}),
' ',
watch(shown, v => d.span(v ? 'present' : 'absent')),
),
// The card stays mounted through its exit animation, then removes itself.
// Toggle rapidly to watch a reversal abort whatever was in flight.
watch(shown, isShown =>
withPresence(
isShown,
phase => d.div(d.strong('Phase: '), phase).classes('demo-card'),
config,
),
),
]);
signal.addEventListener('abort', () => {
d.reconcile(container, null);
});
});
};
A spec says how one direction animates. Everything in it is optional, and whatever you give runs concurrently — the phase advances when all of it is done.
| field | meaning |
|---|---|
classes |
class names added for the duration, then removed |
styling |
CSS properties (kebab-case) applied, then removed |
animation |
{keyframes, options} handed to Element.animate |
fn |
(element, {signal}) => Promise for anything else |
duration |
overrides the duration read from computed style, in ms |
Classes and styling are applied on the next frame, not immediately. An element inserted during this frame has no previously rendered style to transition from, so applying in the same tick produces a jump rather than an animation.
"Next frame" here means a real requestAnimationFrame, deliberately not
dodo's scheduler. The scheduler drains everything queued while it is draining,
so work queued from inside a render lands in that same frame — and since every
reactive render is itself deferred through the scheduler, the element would be
created and styled without ever being painted at its starting state. The frame
setting overrides how a later frame is reached, for tests.
A spec's styles are the state the element animates to, and they stay put when the phase completes — that state is the element's new resting state, and removing it would snap the element back to where it started. When the opposite phase begins, its styles go on in the same frame the previous phase's come off, so there is never a frame showing neither.
An interrupted phase is different: it undoes itself immediately, because its styles describe a state the element is no longer heading towards.
runAnimation on its own defaults to the opposite — restore: true — since a
standalone animation like a shake should leave no trace.
Toggling presence mid-animation aborts the animation in flight: its timers are
cleared, its Element.animate player is cancelled, its classes come off
immediately, and — the part that matters — its completion becomes a no-op, so it
cannot land a moment later and overwrite the newer phase. Detaching aborts it
the same way, and tears the subtree down properly.
If you supply fn, it is handed the same AbortSignal so it can bail out too.
Waiting on transitionend is the obvious approach and it is a trap: a
transition that never starts, a property that did not actually change, or a
transition that was interrupted all leave you waiting forever, with a listener
still attached and the phase stuck.
So no listener is registered. After the classes are applied, the element's
computed transition-duration, transition-delay, animation-duration and
animation-delay are read, and the longest of them is how long the phase takes.
That is knowable, bounded, and correct even when nothing animates — in which
case it is zero and the phase advances immediately.
The element's direct children are measured too. A presence wrapper very often has its transition declared on the content inside it rather than on itself, and measuring only the wrapper would report zero and skip the animation entirely — a silent no-op is a far worse failure than an overestimate.
Set duration explicitly to override it. computedAnimationDuration(element)
is exported if you want the same measurement yourself.
runAnimation(element, spec, options?)The primitive underneath, with no dependency on dodo:
import {runAnimation} from '@3sln/dodo/animate';
await runAnimation(element, {classes: ['shake'], duration: 400}, {signal});
| option | meaning |
|---|---|
signal |
an AbortSignal that cancels and cleans up |
frame |
how a genuinely later frame is reached, defaults to rAF |
restore |
remove the applied styles on completion, true by default |
replace |
a previous spec whose styles come off as these go on |
window |
override the realm |
Same shape as the other modules — the reactive API is a required injection:
import reactive from '@3sln/dodo/src/reactive.js';
import animate from '@3sln/dodo/src/animate.js';
const userSettings = {dodo: myDodo};
const {withPresence} = animate({
...userSettings,
reactive: reactive(userSettings),
});