Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

The Roots runtime: components, DOM backend, router

This is the on-device part of Roots that apps import. It is written in the Roots Lisp dialect and compiled to JavaScript (and to TypeScript that passes tsc --strict). It has no eval, no new Function, no with, and never parses HTML.

SpecifierSourceWhat it is
roots/runtimeruntime/core/index.lispreactivity re-exports, the component model, template helpers, createApp
roots/backend (DOM targets)runtime/dom/index.lispthe backend for web, pwa, ext, ios and android
roots/routerruntime/router/index.lispcreateRouter, RouterView, useRouter, useRoute
roots/uiruntime/ui/index.lispthe UI kit: components, headless machines, skins (ui.md)
(tests only)runtime/core/testing.lisptestBackend, an in-memory backend that records operations

The runtime modules import each other with relative paths that mirror the source tree: core/index.js imports ../reactivity.js, and the router imports ../core/index.js. The build compiles runtime/** and keeps that layout.

CONTRACT.md §2–§5 defines the interfaces. This document describes how the runtime behaves.


1. How rendering works #

Rendering is fine-grained and has no virtual DOM. A component's render(_ctx) runs once. It builds host nodes through the active backend and returns the root, which is a node or a Block. Everything that can change is a small render effect. A dynamicText, a bindAttr, an ifBlock or a forBlock re-runs only when a value it read changes.

createApp(App).mount(backend, "#app")
  └─ App owner ─ setup() ─ render(_ctx) ─┬─ element("Box") ─ setAttr / bindClass / on …
                                         ├─ dynamicText(() => _ctx.count)          (render effect)
                                         ├─ ifBlock(cond, then, else)              (render effect + anchor)
                                         │    └─ branch owner ─ content
                                         └─ forBlock(list, item, key)              (render effect + anchor)
                                              └─ list owner ─ one record per item (the item's scope) ─ content
  • Owners. Each unit of content has an owner: a component, an if-branch, a list, a dynamic component. An owner holds an effect scope with every effect, computed and watcher created while its content rendered, plus the owners nested inside it. Unmounting an owner does four things in order:
    1. calls onBeforeUnmount (parents first);
    2. removes the owner's host nodes;
    3. stops every effect (children first);
    4. calls onUnmounted (children first).
  • List items are scopes. A forBlock has one owner for the whole list; each item is a record that is itself the scope of the effects made while the item rendered (getCurrentScope() returns it there, onScopeDispose registers on it) and that holds the owners of the content nested in the item (components, v-if branches, nested lists). An item that leaves the list takes them along, in the order above; an owner around the list unmounts the items through the list's owner, in the same order. A row costs no owner, no effect scope and no set entry of its own.
  • Blocks. A Block is a range of host nodes: a fragment, a component, or dynamic content. Dynamic blocks keep an invisible anchor node, a comment on the DOM, after their content, and insert new content before it. Blocks can be nested and moved as a whole.
  • Updates are batched and ordered. When a value changes, the affected render effects are queued. They run in one render flush, a "pre" job of the reactivity scheduler, so in a microtask after the change. The flush runs them in creation order, so an ifBlock or forBlock always updates before the content it owns. Content that is about to be removed never re-runs with stale data.
  • Timing, as in Vue. Pre watchers run first, then the render flush, then post watchers. After a state change, await nextTick() sees the updated nodes. Event handlers run inside batch(). Renders and setup run untracked, so they never subscribe an enclosing effect.

2. roots/runtime API #

Reactivity, re-exported from runtime/reactivity.lisp: ref shallowRef isRef unref toValue computed effect effectScope getCurrentScope onScopeDispose batch transaction untrack reactive readonly toRef toRefs toRaw markRaw isReactive flushJobs createSelector RootsError EffectScope and the types Ref and ReadonlyRef.

effect(fn, { scheduler?, cleanup?: "run" | "any" }): scheduler is called instead of queuing a re-run. cleanup: "run" promises that fn calls its onCleanup argument only during its run (as a function declaring no parameter can), so the effect hands it the shared registrar without reading fn.length. The shared registrar (also what a function declaring no parameter reaches through arguments or a rest parameter) registers on the effect that is collecting dependencies at the call: it throws R106 after the run, and during it inside untrack(...), inside a computed's getter and inside a watch getter, where the effect is not the one collecting (a cleanup is never attached to another node silently); called while another effect runs its function, it registers on that one. "any", the default, lets a function that declares the parameter keep its own onCleanup, which works anywhere and at any time. Scopes: a scope stops its effects, then its child scopes, then calls its dispose hooks; once a child scope has stopped on its own, the last sibling takes its slot, so a parent stops its remaining children in no particular order (as in Vue), not in creation order. Inside a list item's render, getCurrentScope() returns the item's record (§1): a Scope (onScopeDispose works, the item's effects and nested content end with it), not an EffectScope with run and stop.

runtime/reactivity.lisp also exports what the component runtime builds on, which roots/runtime does not re-export: runInScope, queueJob, batchCall(fn, arg) (batch for one call with one argument, allocating no closure: event handlers run through it), ReactiveEffect and its base EffectBase (the effect without a scheduler slot that render effects extend), the type Scope, and scopeRemoveEffect(scope, effect): the scope (an EffectScope or a list item's record) gives up an effect it owns without stopping it, the last effect of its list taking the place (for bindings that turn out to read nothing reactive; nothing happens when the scope does not own the effect). An effect's fields are assigned in EffectBase's constructor (the dialect's (:constructor-fields t)), and a ReactiveEffect adds two of its own: scheduler, and registrar, its own onCleanup when its function declares the parameter (null otherwise).

Three more exports are for the component runtime's own objects, again not re-exported:

  • trackDep(dep) and triggerDep(dep) make any object a source of its own, as a ref is: the object declares the four fields of the exported type Dep first (version 0, subs and subsTail null, flags 0; the type of the two pointers is the exported Link). trackDep records it as read by the node running now (nothing when none is); triggerDep says it changed (the caller compares the values itself, and nothing is recorded for a transaction's rollback).
  • selectorBind(selector, key, target) registers target, an object with a queue() method (type SelectorTarget), under one key of a createSelector result: queue() is called when the selector's value moves to that key or away from it, at the end of the write, before the effects that read the old and the new key run; the old key's targets come first, each key's in binding order. It returns false and registers nothing when selector is not a createSelector result. selectorUnbind(selector, key, target) removes one registration. Whether a key is selected now is selector(key). A selector whose scope stopped calls nothing any more.

The graph tests its pointers against null exactly (subs, subsTail, deps, depsTail, the four pointers of a link, an effect's cleanups and scope): each of them is null or an object from the start, never undefined, and code that builds a Dep of its own must keep that.

createSelector(source) answers "is this key the selected one?" with O(1) updates. source is a ref, a computed or a getter; the result is a function isSelected(key), true when key is the source's value (Object.is). A binding or effect that calls isSelected(key) depends on that key only: when the value moves from a to b, only the readers of a and b run again, instead of every row of a list re-running its comparison.

<script setup>
import { ref, createSelector } from "roots/runtime"
const selected = ref(0)
const isSelected = createSelector(selected)
</script>
<template>
  <tr v-for="row in rows" :key="row.id" :class="{ danger: isSelected(row.id) }">…</tr>
</template>

The value is always current, also between the write and the readers' updates. A computed without subscribers that calls it depends on the source itself (it has no push notifications to receive). The selector watches the source from the current scope (the component) and stops with it; keys leave it with their last reader, so a list whose rows come and go does not accumulate them. It is opt-in: a plain row.id === selected keeps working, with one comparison per row on every change.

DEV switches the development checks that cost time: the R215 duplicate-key scan of a pure insertion and the check of a swap's two keys (§2 forBlock), insert's R224 anchor check, and the validation of a component's props when it is created (§3: the R202 and R203 warnings, which read every declared prop once). It is true unless the global __ROOTS_DEV__ is defined (export var DEV = typeof __ROOTS_DEV__ === "undefined" ? true : __ROOTS_DEV__). Roots' production build (roots build) defines DEV false in the bundle (the Roots bundler handles module-binding defines; an esbuild or Vite define cannot fold a live export, but define: { __ROOTS_DEV__: "false" } folds the initializer). The checks the runtime gets for free stay.

watch(source, cb, { immediate, deep, once, flush }) and watchEffect(fn, { flush }) behave like the reactivity versions, with two differences:

  • errors in their callbacks go to the component's error handling (§5);
  • watch(props, cb) watches every prop.

Components:

APINotes
defineComponent(options)Returns options, typed. A function becomes { setup: fn }. Options: name, props, emits, components, inheritAttrs, setup, render.
createApp(Root, rootProps?)→ App: mount(backend, target, options?) returns the root's public instance; unmount(); component(name, C?) registers C, or looks a name up when C is left out; provide(key, value); use(plugin, ...options); onUnmount(fn); config: { errorHandler, warnHandler, globalProperties, navigator }; handle, the object backend.mount returned (the terminal backend's screen handle, for example).
onMounted(fn)Runs once the component's nodes are in the live tree, children first.
onBeforeUnmount(fn)Runs before the nodes are removed, parents first.
onUnmounted(fn)Runs after removal, children first.
onErrorCaptured(fn)(err, instance, info) for errors from descendants. Returning false stops propagation.
provide(key, value) / inject(key, default?, factory?)inject searches the ancestors first, then app.provide. With neither a provider nor a default it returns undefined and warns R206.
nextTick(fn?)A promise that resolves once pending updates are applied.
useSlots() / useAttrs()Call them in setup.
getCurrentInstance()Internal API for libraries.
toDisplayString, normalizeClass, normalizeStyleThe normalizations the helpers use.
runtimeWarn(code, message, instance?)A warning for libraries: it goes to app.config.warnHandler (else console.warn) with the component trace, like the runtime's own (the UI kit's R6xx).

setup(props, { emit, slots, attrs, expose }) returns either the bindings, which templates read through _ctx, or a render function.

Template helpers. The template compiler emits these (CONTRACT §3). node is a host node. Helpers that act on a node also accept a component Block, and then act on the component's root element (R223 if it has none).

HelperBehaviour
element(tag)backend.createElement(tag). Throws R209 outside a render.
text(v) / dynamicText(getter)Text nodes. dynamicText updates only when the string changes.
template(spec) / clone(t)Static templates (CONTRACT §3): clone copies the structure with the backend's cloneNode (from a prototype built once per backend with createElement/setProp; backends with isElement, cloneNode and firstChild) or builds it with the helpers, and returns the collected nodes, root first. A backend with home gets each new prototype once, to keep it where copies are cheap: the DOM backend moves it into an inert document, and a copy belongs to that document until it is inserted (§6; not in Firefox). In a build that defines the runtime's CLONE_BACKEND true (roots build on the DOM targets) clone does not ask the backend whether it can copy, and the code that builds a template node by node is not in the bundle: a backend of your own that is mounted in such a build needs cloneNode, firstChild and isElement (the DOM and test backends have them). Outside such a build the constant is false and the backend is asked. A spec may hold the host primitives Box, Text, Button, Image and Spacer besides lowercase elements; after copying, clone calls the backend's adopt(node, tag) (§4) for each collected Box and Spacer, in document order, before it returns (a template without one has no such list and pays nothing). An interpolation that is the only child of its element has no text node in the copy: the element is collected in its place (<td>{{ x }}</td> costs one node, not two).
bindText(node, getter)dynamicText for an existing text node, or for the element a text run is all the content of (the compiler makes one getter of adjacent text and interpolations, () => "You have " + $.toDisplayString(_ctx.n) + " todos"; the element is a template's copy or made by element): then the first run sets the element's text content (setTextContent, which lets the DOM make the text node without a wrapper; createText + insert on other backends) and the first update finds the text node and keeps it: the first child of the element that is not an element, so an element other code put into it since (an icon prepended through a template ref) is passed over; if the text was empty until then, the text node is made and inserted first.
setText(node, value)bindText's first run without the effect, for an interpolation whose value cannot change while its node lives: the compiler emits it when the interpolation shows the key of the keyed v-for it is in (<tr v-for="row in rows" :key="row.id"><td>{{ row.id }}</td> → `$.setText(n, row.value.id)
insert(parent, child, before)append, but before the host node before.
append(parent, ...children)Children are nodes, Blocks, strings or numbers. Returns parent.
fragment(...children)A static Block (multi-root templates).
setAttr(node, name, value)A static prop. class and style merge with the bound and fallthrough values.
bindAttr(node, name, getter, over?)A reactive prop. A first value that only says "absent" is not written, since a node the backend just made has nothing to remove: null, undefined, and false for a key where false removes (every key but aria-*, data-*, contenteditable, draggable and spellcheck, where false is a value and is written). Later values are always written. The exceptions are class and style (merged), value (null and false are values a radio or checkbox can stand for), and to / replace on a Link; an attr falling through from a parent is always written too, because it is applied over what the root's own template set. So is anything the node may hold already: type and aria-hidden (a Button, a Checkbox and a Spacer come with one), textContent (the node's children), a binding on a component (its root element comes configured), and a binding started with over: true. The compiler passes over where something wrote the attribute to the element before the binding (a static attribute of the same name, also one in a static template; an object v-bind, for its own keys and for the named bindings after it), and h does for $attrs. The same values therefore give the same node at mount and after any update. Hand-written render code that calls setAttr and then bindAttr for one name passes over itself.
bindClass(node, staticClass, getter) / bindStyle(node, staticStyle, getter)Vue normalization: strings, arrays, { cls: cond }, style objects.
on(node, event, handler, modifiers?)A listener. With modifiers it calls onWith; see below.
onWith(node, event, handler, modifiers)on with modifiers (see below): the function that holds the modifier code. The compiler calls it directly on DOM targets, and on only without modifiers, so the bundle of an app that uses neither onWith nor on has no modifier code.
show(node, getter)display: none while falsy. It wins over every other style.
model(node, getter, setter, modifiers?)See below.
ifBlock(cond, then, else?)v-else-if is a nested ifBlock in the else branch. A branch is created only when the condition's truthiness changes.
forBlock(source, renderItem, key?, single?)See below.
component(C, props, slots)See §3.
dynamicComponent(getter, props, slots)The getter returns a component, a registered name, or a tag name. A tag name renders that element with the props as attributes and the default slot as children. A string that names no registered component and is one of script style iframe frame frameset object embed base meta link is refused (R306): those act on the whole document (a <meta http-equiv="refresh"> navigates, and no CSP stops it). Give :is values the app chose, not raw data.
slot(_ctx, name, propsGetters?, fallback?)Calls the parent's slot with a props object whose properties read the getters, so reading slotProps.x inside a binding is reactive. Destructuring slot props in the slot's parameter list reads them once.
resolveComponent(_ctx, name)Looks in the component's own components, then in app.component, trying name, its camelCase form and its PascalCase form. Throws R201 if not found.
templateRef(node, _ctx, name)Sets the setup ref name (or calls a function ref). The value is the host node, or a component's exposed object (else its _ctx). If the ref holds an array (v-for), nodes are pushed into it. The ref is reset to null on unmount.
portal(render, target?)Renders render() in an overlay layer instead of in place: target (a host node, or a function of the portal's anchor returning one), else backend.layer(anchor) (§4). Only an invisible anchor stays in place, so moving the portal (v-for) never moves the overlay. The content belongs to the current owner: it updates and unmounts with it. When the layer is not available yet (the tree is not mounted), the content is attached once it is (after app.mount() or the update that mounted it). A backend without layer renders the content in place. Templates do not emit it; libraries call it (the UI kit's dialogs, menus, toasts).

The lean helpers are what the template compiler emits on DOM targets for a node it knows to be an element copied from a static template (CONTRACT §3). Each does what its general helper does for such a node, without asking what the node is; on a backend that lacks what they use (setTextContent, listenElement) they fall back to the general path.

HelperBehaviour
setElText(node, value)setText for the element that hosts the text: no isElement call.
bindElText(node, getter)bindText for the element that hosts the text.
bindClassEl(node, getter)bindClass(node, null, getter): no look for a component in the node's place, no static class to merge.
onEl(node, event, handler)on without modifiers, through the backend's listenElement (§4). R225 as on.
rowHandler(fn) / onRow(node, event, handler, row)A handler of list items. rowHandler(fn) makes one descriptor (the function, the component it was made in for error routing, the invoker) per handler site and render of the list; onRow stores it and the item's row with the node. On dispatch fn(row, event) runs in a batch with the error routing of every handler. An item costs no closure per listener, and on the DOM two properties per listener.
bindSelected(node, selector, key, apply)A class that depends on selector(key) alone, without a render effect: when selector is a createSelector result, the node's class is normalizeClass(apply(selected)), written at once and then in the render flush (in creation order, where a class binding made at the same place would write) whenever the selection moves to or from key; the binding is registered with the selector (selectorBind of the reactive core) and leaves it with the scope it was made in (the item's record, a v-if branch in it). Returns true. Returns false and does nothing when selector is anything else or no scope is active: the caller binds the class instead ($.bindSelected(…) || $.bindClassEl(…)). apply must be a pure function of its argument: the class for "not selected" is asked once per apply, not once per item.

on modifiers can be an array or an object:

  • stop, prevent, self and once (Vue order semantics);
  • system keys ctrl, shift, alt, meta and exact;
  • mouse buttons left, middle, right;
  • on key* events, key filters: enter, esc, space, up, down, left, right, tab, delete (Delete or Backspace), or any key name in kebab-case (page-down, a).

capture and passive are not supported in 0.1 and warn R222.

model binds by element kind:

ElementBinding
Input, input, textareavalue + input (lazy: change)
Checkbox, input[type=checkbox]checked + change. An array model toggles the element's value.
input[type=radio]checked = (model === value)
selectvalue + change
  • Modifiers: trim and number. number is automatic for type="number".
  • Set type and value before calling model.
  • The value just typed is never written back, so the caret stays put and "1.0" stays "1.0".
  • Other elements warn R214.

forBlock:

  • Sources: an array, a number n (1..n), a string, an iterable, or an object.
  • Item arguments: renderItem receives (item, index), for objects (value, key, index), each read through .value. The index (and key) refs are only made when renderItem declares those parameters (a function declaring none, e.g. with a rest parameter, gets all of them). The item argument is the list's record of the item, not a ref: its .value is the item and is tracked (a binding or effect that reads it runs again when the list gives the record another item object under the same key), it cannot be assigned, and isRef says false. So an item costs no ref of its own.
  • single: true from the template compiler when renderItem returns exactly one host element; the list then inserts it as it is (no look at what was returned). An item whose render threw has no node and stays out of the DOM.
  • With key(item, index): items are matched by key. Reused items are given their item and their index. New items render in order, and removed items unmount. A common prefix and suffix stay in place; an insertion or a removal in one place needs no key map, nor do two items that exchanged places (a swap: the two items move, one when they were neighbours); otherwise only nodes outside the longest increasing run of old positions move, which is the minimum.
  • The scan. The list keeps the keys of its last update (and, when renderItem declares one parameter, a copy of the items). An update computes the new keys, as before, and compares the two key arrays from both ends; where the key and the item are what they were, the record is not looked at. The keys are compared as a Map compares them (SameValueZero), the items by identity. The source array is never trusted to be unchanged: the list compares with its own copy, so an array that was reordered or spliced in place and then rendered again is reconciled like a new one. A swap of two rows among 1,000 runs the key function 1,000 times and touches two records.
  • Without a key: items are matched by position and patched in place, as in Vue.
  • Duplicate keys: they warn R215 when the list is reconciled with them (new items, or a reordered middle), but every item still renders. The scan of a pure insertion (a creation, an append: every key of the list) and the check of a swap's two keys against the kept items are development checks, skipped when DEV is false; the key map of any other reordered middle finds duplicates for free, so that warning stays in production.
  • Bindings without a dependency are released. A binding that only writes a node (dynamicText, bindText, bindAttr, bindClass, bindStyle, show, and the lean bindElText and bindClassEl) and whose first run read no reactive source can never run again (:label="'Save'", a prop a library binds through a getter that returns a constant): after that run its scope lets go of it (scopeRemoveEffect of the reactive core) and it is stopped, so it costs no memory and nothing to dispose. A first run that failed keeps its effect. The effects of blocks (ifBlock, forBlock, dynamicComponent, portal, model, a Link's active state) are never released: they own content or a listener's state.
  • Render effects re-run only when a source really changed: a binding that depends on a computed or prop whose value stayed the same does not run. A render effect that runs is stamped with the id of the render flush (the reactive core's ranIn, free for render effects, which never enter its queue); one that runs again in the same flush is counted, and past 100 runs it is stopped with R220.
  • Items are scopes (§1): a kept item whose value is the same object and that received no index or key ref is not touched by an update; the others are given their item (readers of item.value run again when it is another value, Object.is), index and key. The list's block keeps no node list of its own: its nodes are its records', so a block around the list that moves or removes it as a whole (an enclosing keyed list, a v-if) walks the records as they are now.

3. Components #

Props passed to component() are { name: value | getter }. Every function is a getter except onX handlers. To pass a function as a prop value, wrap it: fn: () => myFn.

Declared props:

  • They are read lazily through a cached computed of the parent's getter, so they are reactive and their identity is stable.
  • props is a readonly object. Writes throw R204, and so do delete, Object.defineProperty, Object.setPrototypeOf and Object.freeze / seal / preventExtensions. Object.keys and toRefs work on it.
  • Declarations follow Vue:
    • an array of names, or { name: Type | [Types] | { type, default, required, validator } };
    • kebab-case names are camelized;
    • default functions are factories (unless type: Function) and run once per component, at the first read that needs the default: at mount in development (validation reads every prop), possibly later when DEV is false. Wherever that read happens, the factory runs as setup does, with the component current (inject and getCurrentInstance work in it) and untracked;
    • Boolean props are false when absent and true for "" (or the hyphenated name).
  • Problems only warn, and only in development builds: a missing required prop warns R202, and a wrong type or a failed validator warns R203. Validation runs when the component is created and reads every declared prop once; when DEV is false (§2: a roots build output) it does not run, so there is no warning and a validator is never called, as in Vue's production build. The props themselves are the same either way: defaults, Boolean casting and readonly access do not depend on it.

Attrs are the passed keys that are neither props nor listeners for declared emits. attrs is a readonly object like props (R204); a component that was passed none gets the one empty view all such components share (nothing can be defined on it, so it carries nothing from one component to another).

  • Fallthrough. Attrs land on the component's single root element, found through nested components and fragments of one child.
  • Merging. class merges, and style merges with the parent winning. onX attrs become listeners on the root element (delegated for the common events, §6). Other attrs are set as props.
  • No single root element (fragment, dynamic root, text root): the attrs warn R211. Use inheritAttrs: false and $attrs instead.

Emits.

  • emit("select", a, b) calls the onSelect prop.
  • Kebab-case names find camelCase handlers.
  • update:modelValue calls onUpdate:modelValue.
  • An event not listed in a non-empty emits warns R217.

_ctx, the instance proxy of CONTRACT §2:

  • Read order: setup bindings (refs unwrapped), then props, then $slots, $attrs, $emit, $el, $props, $refs and $parent, then app.config.globalProperties (the router adds $router and $route).
  • Writes: writing a binding sets .value for a ref, or replaces a plain binding. Writing a prop throws R204. Writing anything else throws R219.
  • Unknown names read undefined and warn R218 once per name, which catches template typos.

Lifecycle order.

  • When a tree mounts, mounted runs children first.
  • A v-if or v-for that adds components later calls their mounted after the flush inserted them.
  • When a tree unmounts, beforeUnmount runs parents first and unmounted children first.

4. Mounting and backends #

app.mount(backend, target, options) renders the root component:

  • Single static root: a root that is one static host node is passed to backend.mount(root, target, options).
  • Several roots or dynamic root content: the content is first placed in a Box.

Notes for backend implementers (CONTRACT §5):

  • insert(parent, child, anchor) must move a child that is already attached (DOM insertBefore semantics). Keyed lists rely on it.
  • isElement(node) is optional: with it the runtime asks the backend what is an element (attrs fall through onto elements only, an interpolation hosted by an element is bound differently from one in a text node); without it, it remembers the nodes element() made in a WeakSet. The DOM backend answers nodeType === 1, the test and terminal backends kind === "element".
  • cloneNode(node) and firstChild(node) are optional. With them (and isElement), static templates are copied (attributes, text and children, no listeners); without them, built node by node. The DOM backend uses node.cloneNode(true). collect(ops, root, out) is optional on top: the walk over a copy that finds the collected nodes, done by the backend with direct property reads instead of one call per step (the DOM backend has it); the runtime's own walk remembers the way back up instead of asking parentNode.
  • adopt(node, tag) is optional (it belongs to cloneNode): a copy has its prototype's attributes and children, not what createElement keeps with the element of a host primitive. clone therefore passes every collected Box and Spacer of a copy to adopt with its primitive name. The DOM backend sets the node's base class there (r-box, r-spacer, §6), so that a class bound to the copy later, or falling through onto it, is written after the base class as on an element createElement made. Only collected nodes are adopted: nothing can write to the others. The test and terminal backends keep no such state and have no adopt.
  • setTextContent(node, text) is optional (it needs firstChild): an interpolation that is the only child of its element is first written as the element's text content, so the DOM creates the text node itself (vanilla's td.textContent = x); the first update then reads firstChild once and writes that text node from then on. Without it the runtime creates and inserts the text node on the first run.
  • listen(node, event, handler, data, invoke) is optional: the runtime's own listeners (on without modifiers, onX attrs) go through it when it exists, and the backend stores handler and data (the component, for error routing) with the node and calls invoke(handler, data, event) on dispatch; invoke is one function for every listener, so registering one allocates nothing. Several listeners for an event on a node run in registration order, as addEvent listeners would. Without it the runtime wraps the handler in a closure and calls addEvent (the terminal backend).
  • listenElement(node, event, handler, data, invoke?) is optional: listen for an element the runtime just copied from a static template (onEl, onRow), which is never a primitive with an inner control and has no listener yet in the usual case. Without invoke the handler is a descriptor that brings its invoker (handler.invoke(handler, data, event)), shared by all items of a list. The DOM backend has it (§6); for the others the runtime uses listen, else addEvent.
  • home(proto) is optional: called once for each new template prototype, which the backend may move to where copying is cheaper (the DOM backend: §6).
  • clear(parent) is optional: a keyed list that is all its parent holds and loses every item (cleared, or replaced with no key kept) is removed in one operation (textContent = "" on the DOM). Hooks keep their order: every beforeUnmount first, then the nodes go, then effects stop and unmounted runs.
  • Host nodes must be objects. The runtime keeps its per-node state (the class and style sources it merges, the tag and type of an element v-model or Link needs) in one symbol-keyed own property of the node, Symbol("roots.nodeState"): a property read where a WeakMap lookup would hash the node. It shows in Object.getOwnPropertySymbols(node) and nowhere else; a copy made by cloneNode must not carry it (the DOM's does not). A node that takes no new property (frozen, sealed, non-extensible) gets its state in a WeakMap instead, so such hosts work, at the old cost. A bound class allocates its node's state only once it has a class to write: :class="{ danger: selected }" on a thousand unselected rows keeps none. The DOM backend keeps its own state (a primitive's base class, a Checkbox's input, handlers) in symbol-keyed properties too.
  • setProp and a new node: a binding's first value is not sent when it only says "absent" (bindAttr, §2), so createElement(tag) must return a node on which every prop that was not set is absent: setProp(node, key, null) on it would change nothing. The exceptions are type and aria-hidden, which a backend may set when it makes a primitive (the DOM's Button, Checkbox and Spacer): their bindings are always written.
  • setProp(node, "class", string): the whole class string, already merged. It is "" when there is no class.
  • setProp(node, "style", object): the whole style, as kebab-case CSS property names (--custom kept) mapped to string values. Properties that are missing were removed.
  • setProp(node, "value" | "checked", …) comes from bindAttr and model, and label comes for Button and Checkbox.
  • Link with to: the runtime converts to to href with the router and handles the click itself (§6). The backend only sees href, aria-current and the class r-link-active r-link-exact-active.
  • Events must be DOM-shaped (target.value, target.checked, key, ctrlKey…, button, preventDefault(), stopPropagation(), defaultPrevented), as CONTRACT §4 says.
  • layer(node) is optional (CONTRACT §5): the node overlay content (portal) goes into for the tree holding node, or null while that tree is not mounted. The DOM backend returns <body> once the node is connected; the terminal the screen's root; the test backend the root container.

5. Errors #

Errors from setup, render, render effects, watchers, lifecycle hooks, event and emit handlers, and v-model setters are offered to onErrorCaptured hooks of the ancestors (false stops them), then to app.config.errorHandler(err, instance, info).

info is one of:

  • setup function, render function, render effect, effect;
  • watcher callback;
  • mounted hook, beforeUnmount hook, unmounted hook;
  • event handler, v-model handler.

A component whose setup or render failed renders nothing.

Errors are never swallowed. When nobody takes one, it is rethrown:

  • during app.mount(), by mount() itself, after mounting (an AggregateError if there are several);
  • in an event handler, from the handler, to the event source;
  • in an update, in a microtask after the update finished.

Warnings go to app.config.warnHandler(message, instance, code), else to console.warn with a component trace.

CodeMeaning
R201unknown component (resolveComponent)
R202missing required prop (warning; development builds, DEV)
R203invalid prop type or validator failure (warning; development builds, DEV)
R204props are readonly
R205lifecycle, provide or inject called outside setup
R206inject: nothing provided and no default (warning)
R207component has no render function
R208setup returned a Promise (async setup needs Suspense, which is not in 0.1)
R209template helper called without an active backend
R210app already mounted
R211attrs but no single root element (warning)
R212ref="x": the binding is not a ref (warning)
R213not a component
R214v-model on an unsupported element (warning)
R215duplicate key in v-for (warning)
R216app.use: not a plugin
R217emitted an undeclared event (warning)
R218template reads an unknown name (warning)
R219assignment to an unknown _ctx name
R220a binding re-triggered itself more than 100 times in one update
R221helper called without _ctx
R222capture or passive modifier is not supported (warning)
R223node helper used on a component without a single root element
R224insert: the anchor is the parent itself (a development check, DEV)
R225on: the handler is not a function, so it is ignored (warning)
R301DOM mount: no element matches the selector
R302DOM mount: bad target
R303innerHTML, outerHTML or srcdoc refused (XSS)
R304inline on* attribute refused
R305capitalized tag that is not a host primitive
R306<script> and <style> elements refused; <component :is> with a string naming a document-level tag (iframe, meta, base, link, object, embed, frame…)
R307javascript: or vbscript: URL dropped, or a data: URL as the document of an iframe/frame/embed/object (warning)
R308not an attribute name (spaces, quotes, <>/=, or a name the browser refuses): dropped (warning)
R401no route matches (warning; add a * route)
R402useRouter() in an app without a router
R403invalid route definition or router option (the message names the route and the key)
R404redirect loop (a navigation was redirected more than 10 times)
R405unknown route name
R406missing route param
R407the browser's history refused the entry; the navigation resolves false (warning)
R408a lazy route component did not load, or its loader returned no component
R409an onBeforeRouteLeave / onBeforeRouteUpdate guard is not a function

6. DOM backend (runtime/dom) #

backend implements CONTRACT §5 with the host primitives of §4 as native elements:

PrimitiveDOM
Box<div class="r-box">
Text<span>
Button<button type="button">
Input<input>
Checkbox<label class="r-checkbox"><input type="checkbox"><span>label</span></label>
Image<img>
Link<a>
Spacer<div class="r-spacer" aria-hidden="true">
  • Checkbox. The label element makes the text a real, clickable label. Everything except class, style and label (checked, disabled, id, aria-*, listeners) goes to the inner <input>, so $event.target is the input. Children appended to a Checkbox become part of its label.
  • Lowercase tags pass through. SVG tags get the SVG namespace.
  • CSS. baseStyles is the CSS for the primitives (.r-box{display:flex;flex-direction:column…}, .r-spacer, .r-checkbox). The build must ship it in the app stylesheet. The backend never adds inline styles that would override utility classes.
  • Props vs attributes.
    • value is set as a DOM property (skipped when equal, so the caret stays put). checked, selected, indeterminate and muted are DOM properties.
    • Boolean attributes are removed for false, null and undefined.
    • aria-*, data-*, contenteditable, draggable and spellcheck keep the string "false".
    • class keeps the primitive's base class.
    • style goes through style.setProperty: a strict CSP allows it, and stale properties are removed.
  • Security. These hold for every write, wherever the name and the value come from: a template, the keys of v-bind="obj", fallthrough attributes, h()'s $attrs, the props of <component :is="'a'">, a static template.
    • Names are judged as the DOM stores them. setAttribute lower-cases the name on an HTML element, so onClick, srcDoc and HREF are onclick, srcdoc and href: every rule below compares names without regard to case.
    • No innerHTML, outerHTML or srcdoc (R303, thrown).
    • No inline on* attributes: any name that starts with on (R304, thrown). Listeners are functions, added with addEventListener; a string is never made a handler.
    • No <script> and no <style> elements (R306, thrown).
    • javascript: and vbscript: URLs in href, src, action, formaction, poster, xlink:href, data and cite are dropped with warning R307, after removing what a browser ignores (leading spaces and control characters, tabs and newlines inside the scheme). So is a data: URL in the src of an iframe, frame or embed and the data of an object: it would be a document of the URL's author inside the app. Other URLs (https:, mailto:, data: images) are the app's to validate.
    • A URL value is made a string once; the string that was checked is the one written.
    • A name that is no attribute name (it has a space, a quote, <, >, / or =, or the browser refuses it: engines differ on names such as @x) is dropped with warning R308; the other keys of the object are applied.
    • Text is always text.
    • The runtime never assigns a string to an HTML or script sink, so it runs unchanged under Trusted Types (require-trusted-types-for 'script').
    • The suite runs under script-src 'self'; style-src 'self' and fails on any CSP violation.
    • Cost: a name is classified the first time it is written and remembered (up to 1024 names); a write then looks its name up once.
    • Not covered: what a value means. :style, :class, :href with an https: URL, the keys of v-bind="obj" and the string of :is take trusted input only (docs/templates.md §7).
  • Mounting. mount(root, target) takes a selector, an element (or any DOM node), or nothing, which means document.body. It replaces the target's content without using innerHTML.
  • Template prototypes. home moves the prototype of a static template into the content of a <template> of its own, i.e. into the page's inert template document. A copy (cloneNode(true)) is made there and adopted by the page when it is inserted. Chromium makes and inserts such copies faster than copies made in the page's document (creating rows: about 1.15× in the rendering benchmark's script time), WebKit slightly faster; in Firefox it is slower (0.7–0.9×), so there prototypes stay in the page's document (the backend reads navigator.userAgent once: every engine but Gecko homes). A prototype with a custom element in it (a name with a dash, an is attribute) is not moved either, so its copies are upgraded when they are made. Until a copy is inserted its ownerDocument is the inert document where prototypes are homed (CONTRACT §5 lists what that means); the backend itself maps that document to document when it delegates a copy's listeners.
  • Event delegation. Listeners for click, dblclick, auxclick, contextmenu, input, beforeinput, keydown, keyup, mouse{down,up,move,over,out}, pointer{down,up,move,over,out}, focusin and focusout are not added to each element: every document gets one listener per event type, and an element keeps its handlers in three symbol-keyed properties per event type: the handlers, their data and their invokers (one value each for one handler, arrays for several; a list is replaced, never changed, so a dispatch in progress keeps the one it read). listen (CONTRACT §5) stores the compiled handler, the component and the runtime's invoker as they are; addEvent stores the handler with a plain invoker and returns a bound remover. Registering a listener through listen allocates nothing (a node's further handlers for one event share three small arrays; addEvent returns a bound remover). A listener is identified by its handler and its invoker, so the remover of an addEvent registration leaves a listen entry holding the same function alone; a handler that is not a function is refused by on (R225). listenElement (onEl, onRow) is the same registration for an element fresh from a template: once document listens to an event type, it reads the node's handler property and writes two or three properties, without asking the node for its document or for a Checkbox's input. A descriptor (rowHandler) is stored without an invoker beside it (two properties); the dispatch calls descriptor.invoke(descriptor, data, event). A node's second listener for an event moves all of them into the lists, in registration order, whatever their kind. The document's listener walks event.composedPath() from the target up and calls each node's handlers as native listeners would be called: in registration order, with event.currentTarget set to the node (restored afterwards; an event that reaches no Roots handler is left as it is), modifiers (.stop, .self, .once, keys…) applied, until a handler calls stopPropagation(). A handler that throws is reported like a throwing native listener and the others still run. Non-bubbling events (focus, blur, mouseenter, scroll…), touch and wheel events (passive at the document), change, submit and all others get native listeners (listen wraps the handler for them). Rendering 1,000 rows no longer adds 2,000 listeners, and neither 2,000 closures. What differs from per-element listeners:
    • a native listener that your own code adds to an ancestor (addEventListener) runs before the Roots handlers below it, and its stopPropagation() keeps them from running; a Roots handler's stopPropagation() cannot stop such a listener;
    • stopImmediatePropagation() stops the walk like stopPropagation(), but the other Roots handlers on the same element still run;
    • event.eventPhase is the bubbling phase also for the target's own handlers;
    • an event dispatched on an element that is not in a document reaches no Roots handler.

7. Router (runtime/router) #

A running app with its own router (Routing by example walks through it):

A small app with a routerstatic previewdemos/router-app
import { createRouter, useRouter, useRoute } from "roots/router"
const router = createRouter({
  routes: [
    { path: "/", component: Home, name: "home" },
    { path: "/users/:id", component: User, name: "user", props: true },
    { path: "/settings", component: () => import("./pages/Settings.vue") },   // loaded on demand
    { path: "*", component: NotFound },
  ],
  history: "hash",          // "hash" | "history" (base: "/app") | "memory" (initial: "/x")
})
router.beforeEach((to, from) => (to.meta.auth && !loggedIn ? "/login" : true))
createApp(App).use(router).mount(backend, "#app")

Everything in this chapter works with the three histories and on every target. Scrolling and focus need a window: on the terminal (memory history) the hooks run, and nothing scrolls.

  • Routes.

    • A path has static and :param segments. Static segments beat params at the same position.
    • * is the not-found route, with params.pathMatch.
    • Params are decoded.
    • query maps a repeated key to an array.
    • params and query are objects without a prototype: their keys come from the URL, so toString, constructor and __proto__ are keys like any other (?__proto__=a is query["__proto__"] === "a"). Read them by key or with Object.keys; they have no hasOwnProperty method (use Object.hasOwn(query, key) or key in query).
    • A route has the keys path, component, name, props, meta, redirect, alias, beforeEnter and children. Invalid definitions throw R403 at createRouter, and the message names the route and the key. That includes what the router does not support: any other key (components, sensitive, …), optional, repeatable and pattern params (:id?, :rest+, :id(\d+)), and * inside a longer path.
  • Lazy components. A component that is a function without arguments is a loader. The router calls it when the route is first visited, waits for the promise, and uses the module's default export (or the component itself). The build puts the imported page into a file of its own.

    { path: "/checkout", component: () => import("./pages/CheckoutPage.vue") }
    • The loader runs once; later visits are synchronous.
    • While the page loads, the old page stays and the URL does not change.
    • A load that fails rejects the navigation with R408 (the original error is its cause); the next visit tries again.
    • A setup function used as a page takes arguments ((props, ctx) => …) or is wrapped: defineComponent(setup).
  • Redirect and alias.

    { path: "/home", redirect: "/" },
    { path: "/me", redirect: { name: "user", params: { id: "me" } } },
    { path: "/u/:id", redirect: (to) => `/users/${to.params.id}` },
    { path: "/users/:id", component: User, alias: ["/people/:id"] },
    • A route with redirect needs no component and is never entered: guards see the target only. A redirected push makes one history entry.
    • An alias is another path for the same route. The page stays mounted when the reader moves between a route and its alias. A name always gives the route's own path.
    • An alias has every param of its route, under the same names (the page's props are named by the route). Other static segments, another order and more params are fine; a missing one is R403:
      { path: "/users/:id", component: User, props: true, alias: "/people/:id" }   // fine
      { path: "/users/:id", component: User, props: true, alias: "/people/:pid" }  // R403: no param :id
  • Nested routes. children are routes inside a parent. The parent's component renders the matched child with its own <RouterView/>.

    { path: "/admin", component: AdminLayout, meta: { staff: true }, children: [
        { path: "", component: AdminHome },            // /admin
        { path: "stock", component: Stock },           // /admin/stock
        { path: "orders/:id", component: Order, props: true },
    ] }
    <!-- AdminLayout.vue -->
    <template><Box><AdminTabs /><RouterView /></Box></template>
    • A child's path is relative to its parent; "" is the parent's own path; a path that starts with / is absolute (the parent still wraps it).
    • The parent stays mounted while the reader moves between its children.
    • A child has its parents' meta too (its own keys win).
    • Without a "" child the parent's path matches alone and its RouterView is empty. Add redirect: "/admin/stock" to the parent to send it to a child.
    • route.matchedRoutes lists the matched routes from the outermost parent to route.matched.
    • A * route cannot be a child, and a parent with children needs a component.
  • Histories.

    • hash keeps the route after #.
    • history uses pushState under base.
    • memory is an in-memory stack.
    • Without window (the terminal), the router always uses memory history.
  • Navigation.

    • push(to) and replace(to) return Promise<boolean>: true when navigated, false when cancelled or superseded by a newer navigation.
    • When every guard is synchronous and no lazy component has to load, the route updates synchronously.
    • to is a path (relative paths resolve against the current route) or an object { path | name, params, query, hash }. An unknown name throws R405 and a missing param throws R406.
    • A location is always inside the app. A URL of another origin keeps its path only (https://other.example/admin is /admin), and a target whose path would lead out as an href (x:https://other.example/p, /.//other.example/p, x:/\other.example/p) becomes a plain path with one leading slash, which matches the * route. href() therefore never returns a link to another origin; link to other sites with a plain <a href>.
    • If the browser refuses the history entry (pushState throws), the navigation resolves false with warning R407 and the route stays.
    • back(), forward() and go(n) move through the history.
    • to.navigationType says how a location was reached: "push", "replace" or "pop" (back, forward, go(n); also a reload of an entry that has a saved scroll position). An address the reader types after the # is a "push": it is a new page. Guards, afterEach, afterRender and useRoute() have it.
      router.afterEach((to) => { if (to.navigationType !== "pop") closeMenus() })
  • Guards. A guard is (to, from) => …. It can return false to cancel, a location to redirect (R404 after 10 redirects), or a promise of either. They run in this order:

    1. onBeforeRouteLeave guards of the components that are left;
    2. router.beforeEach(guard) (it returns a remover);
    3. onBeforeRouteUpdate guards of the components that stay;
    4. beforeEnter of the routes that are entered (a guard or an array). It does not run when only params, query or hash change, and a parent's does not run when the reader moves between its children.
      { path: "/admin", component: Admin, beforeEnter: () => isStaff() || "/login" }
    5. Lazy components load.
    • A back/forward that ends without a navigation puts the URL back: the address bar always names the page on screen. That holds when a guard cancels it, when a guard or redirect sends it on to a page that is then refused, when the reader pressed Back twice while a guard was still deciding, and when a push overtakes it (a lazy page still loading) and is refused itself. The history returns to the entry the reader was on, however many steps that is. A redirect to the route the reader is already on does the same; the entry gone back to is not rewritten.
    • A typed address that is cancelled shows the current route's address again (the typed entry stays in the history, with the current address).
    • Errors thrown by a guard reject the navigation.
    • afterEach(hook) runs after each completed navigation, before the new page has rendered. afterRender(hook) runs after it has rendered and the router has scrolled. Both return a remover.
      router.afterRender((to) => { document.title = String(to.meta.title ?? "") })
    • onError((error, to, from) => …) sees every navigation that failed: a guard threw, a lazy component did not load. push() and replace() reject either way. A failed back/forward puts the URL back; without an onError hook its error is an unhandled rejection.
      router.onError((error) => toast.error("This page could not be opened", String(error.message)))
    • The initial navigation (at app.use(router)) runs the guards too. router.isReady() resolves when it completes.
  • Guards in components. onBeforeRouteLeave(guard) and onBeforeRouteUpdate(guard) are called in setup() and end with the component. Leave runs before a navigation leaves the component's route; update runs when the route stays and its params, query or hash change.

    import { onBeforeRouteLeave } from "roots/router"
    let timer = 0
    onBeforeRouteLeave(() => { clearTimeout(timer) })                 // stop work that belongs to this page
    onBeforeRouteLeave(() => !dirty.value || confirm("Discard your changes?"))
    • The component's route is the one of the RouterView that renders it: for a nested layout, the parent route.
    • A component outside every RouterView (the app's header) is on the matched route as a whole: leave runs when the reader goes to another route, update when the same route stays.
    • Outside setup() they throw R205; a guard that is no function throws R409.
  • Scrolling. After a navigation has rendered, the router scrolls the window. Without an option:

    • back and forward return to where the reader was on that page;
    • a location with a #hash scrolls to the element with that id;
    • a new page starts at the top;
    • the same page (only the query changed: a filter, a tab) and the first page stay where they are.

    scrollBehavior(to, from, savedPosition) decides instead. It returns { top, left }, { el, top, left } (a selector, "#id" or an element; top and left are then the space kept free above and beside it), any of them with behavior: "smooth", a falsy value for "do not scroll", or a promise. savedPosition is { left, top } on back/forward and after a reload, else null.

    createRouter({
      routes,
      scrollBehavior: (to, from, saved) => saved ?? (to.hash ? { el: to.hash, top: 64 } : { top: 0 }),
    })
    • The position of each history entry is kept in history.state, next to the entry's number: { position, scroll: { left, top } }. It is written when the reader leaves the entry through the router and, while they stay, 150 ms after the window stopped scrolling (one replaceState per scroll gesture). So a reload returns to where the reader was, and an entry left with Back keeps its position over a reload of another entry. pagehide and visibilitychange write once more as a last attempt; a browser may drop what is written while the document goes away, so only scrolling of the last 150 ms before a reload can be lost.
    • The router sets history.scrollRestoration = "manual" while the app is mounted: the browser would restore before the page has its content.
    • Content that arrives after the route has rendered (rows of a query, a lazy image) makes the document tall enough later. The router keeps scrolling to the position for up to 90 frames, and stops when the reader scrolls, taps or types.
  • Focus. createRouter({ focus: "h1" }) moves the keyboard focus to the first match when a new page was opened, so that keyboard and screen-reader users land on it. It does not do so on the first page, on back/forward, when only the query changed, or while the reader types in a field. An element that cannot take the focus gets tabindex="-1". A function (to, from) => target is asked on every navigation and decides for itself.

    createRouter({ routes, focus: "main h1" })
  • RouterView renders the matched route's component; inside a parent's component it renders the child. A new page component mounts only when the matched route changes. When only params change, its props update in place. props: true passes the params as props; props can also be an object or a function (route) => props. A function inside a props object is passed as it is, like any other value (props: { load: fetchBooks } gives the page the function). Attrs on RouterView go to the page.

  • Links. <Link to> (the host primitive) and RouterLink, with the props to and replace.

    • The router sets href (#/path in hash mode).
    • A plain left click navigates without a page load. Ctrl/meta/shift/alt-clicks, other mouse buttons, target="_blank" and prevented events are left to the browser.
    • The active link gets class="r-link-active". The exact match also gets r-link-exact-active and aria-current="page".
  • Composables.

    • useRouter() returns the router (R402 without one).
    • useRoute() returns a reactive view of the current route: path, fullPath, params, query, hash, name, meta, matched, matchedRoutes, navigationType.
    • Templates can use $router and $route.
    • router.currentRoute is a ref.
  • Errors of this chapter. R403 invalid route definition or router option; R404 more than 10 redirects; R408 a lazy component did not load, or its loader returned no component; R409 a route guard that is no function.

Not supported: named views (components), optional, repeatable and pattern params, and a * route inside children. Each of them is an R403 at createRouter, not silence.

8. Testing #

runtime/core/testing.lisp exports testBackend, an in-memory backend:

  • createRoot() returns a container to mount into;
  • serialize(node, { anchors }) returns HTML-like text with sorted props, for example <Box class="a"><Text>1</Text></Box>;
  • query(root, sel) and queryAll(root, sel) find nodes by Tag, #id, .class or [attr=value];
  • dispatch(node, type, init) sends an event that bubbles and supports stopPropagation and preventDefault;
  • setValue(node, v) and setChecked(node, b) change an input and dispatch its event;
  • textContent(node) returns the text;
  • ops and clearOps() record every backend call. "move <li> …" entries show keyed moves.
const root = createRoot()
createApp(Counter).mount(testBackend, root)
dispatch(query(root, "Button"), "click")
await nextTick()
serialize(root)   // '<Box><Button>1</Button></Box>'

The suites live in runtime/core/tests/ and are written in Roots Lisp:

SuiteWhat it coversHow it runs
core-test.lispcomponents, helpers, blocks, errorsNode with testBackend
router-test.lispthe routerNode with memory history
dom-test.lisp + browser-driver.lispthe DOM backendChromium via Playwright, under a strict CSP, with real keyboard and mouse input

Run them with bin/test-system roots-runtime-core/tests or roots test. The system also type-checks every runtime module with tsc --strict and scans the compiled JavaScript for eval, new Function, with and HTML sinks.