Agents Index

This file contains the concatenated content of all cards to help LLMs understand the available documentation.


Scoped Styling (/style.md)

Scoped Styling

@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.

css

A 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.

Demo Code (/demos/style-demo.js)

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);
    });
  });
};

Detaching

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.

Imperative use

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);
  }
}

Context and shadow boundaries

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.

Baking your own

import style from '@3sln/dodo/src/style.js';

const {scoped} = style({dodo: myDodo});

The reconcile function (/reconcile.md)

The reconcile function

The 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:

reconcile(target, null | []) (Cleanup)

Demo Code (/demos/reconcile-demo.js)

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, []);
    });
  });
};

Reactivity (/reactive.md)

Reactivity

@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.

The Cell protocol

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:

Sources

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.

Rendering with watch

import {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.

Demo Code (/demos/reactive-demo.js)

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);
    });
  });
};

Adapters

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)));

Writing your own

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.

Going the other way

Baking your own

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.

Scheduling

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}});

Observing Elements (/observe.md)

Observing Elements

@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.

Cells

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}.

Components

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.

Demo Code (/demos/observe-demo.js)

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);
    });
  });
};

What actually gets observed

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.

Feedback loops

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.

Environments without observers

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.

Baking your own

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),
});

Introduction to Dodo (/introduction.md)

Introduction to Dodo

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.

Core Primitives

Everything in Dodo revolves around a few key functions:

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.

Demo Code (/demos/hello-world.js)

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, []);
    });
  });
};

Customization

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.

Optional Modules

Dodo ships five modules that its core neither imports nor knows about. Ignore them entirely and Dodo behaves exactly the same.

The first four build on the Cell protocol; style stands alone.


Element Creation (/elements.md)

Element Creation

Dodo provides two primary ways to create element VNodes: the low-level h() function and a set of convenient HTML helper functions.

The h(tag, ...children) function

The h() function is the core of element creation.

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' });

HTML Helpers

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' })

Chained setters

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

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.

Demo Code (/demos/elements-demo.js)

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, []);
    });
  });
};

Customization (/customization.md)

Customization

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.

ClojureScript Example

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.

Demo Code (/demos/custom-dodo/custom-dodo.cljs)

(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)))))))

Describing your maps

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 isMap and mapGet for a custom collection, you must also override mapIter or mapEach. Without one of them dodo will walk your maps as if they were plain objects and find nothing.


Context (/context.md)

Context

@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.

Nesting

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 DOM

Shadow roots are a real boundary, and you choose which side of it your data belongs on:

Both kinds merge together for a consumer that can see them, ordered by depth.

Demo Code (/demos/context-demo.js)

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);
    });
  });
};

Imperative API

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

Baking your own

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 note on movement

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.


Components: alias & special (/components.md)

Components: alias & special

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.

Demo Code (/demos/alias-demo.js)

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 Functions

While a plain JavaScript function that returns a VNode can work, alias provides key optimizations and capabilities:

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.

Important: A special component is fully responsible for managing its own children and performing cleanup. If you use d.reconcile() inside the update hook to render children, you must call d.reconcile(element, []) inside the detach hook to clean them up.

Demo Code (/demos/special-demo.js)

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, []);
    });
  });
};

Enter and Exit Animation (/animate.md)

Enter and Exit Animation

@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.

Demo Code (/demos/animate-demo.js)

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);
    });
  });
};

Animation specs

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.

Where a phase's styles end up

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.

Interruption

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.

How long a transition takes

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

Baking your own

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),
});