Rendering model
xoong draws nothing twice. A change to a signal wakes the few effects that read it, and each effect writes one DOM node. There is no virtual DOM to diff and no tree to re-render.
Signals
All of reactivity is five functions in @xoong/core.
A write to a signal marks what read it. Only the effects that depend on it re-run, and each writes one node. In motion, a dot shows one write: to first, then on to the computed value, to the two effects that read it, and from each effect to its own DOM node on the screen, while the code that causes the step is lit. After a pause the same happens for a write to last. Use the slider, or press a number, to look at one step.
signal(v)holds a value. Read it with.value(tracked) or.peek()(not tracked); write with.value =or.update(fn). Writing a value that is the same byObject.isdoes nothing.computed(fn)is a derived value. It is lazy (it runs when read), cached until a signal it read changes, and lets go of its sources when nothing watches it.effect(fn)runs now and again when a signal it read changes. It may return a cleanup function. It returns a function that disposes it. An effect is for side effects: never fetch in one. Declare the read on a view instead.batch(fn)groups writes. Outside a batch each write flushes the effects it affects at once; inside, they flush once when the outermost batch ends.untracked(fn)reads without subscribing.
Two safety nets: an effect that keeps writing a signal it reads is stopped after 10,000 rounds with an error that says so, and when an effect throws, the others still run; the errors are raised together afterwards.
import { signal, computed, effect, batch } from '@xoong/core';
const first = signal('Ada');
const last = signal('Lovelace');
const full = computed(() => first.value + ' ' + last.value); // lazy, cached
const stop = effect(() => { document.title = full.value; }); // runs now, and again when full changes
batch(() => { first.value = 'Augusta'; last.value = 'King'; }); // one run of the effect, not two
stop(); // a returned function disposes itTemplates
html is a tagged template. The strings of one call site are parsed once into a <template> and cloned for each render; the values are bound to the cloned nodes. The bindings are the whole language:
| You write | It does |
|---|---|
${() => x.value} as a child | An effect owns that spot. A function is reactive; a plain value is written once. Arrays, nodes and nested html results work. |
class=${() => ...} | A reactive attribute. null or false removes it; true sets it empty. |
?hidden=${() => ...} | A boolean attribute (toggleAttribute). |
.value=${v} | A DOM property, with its case kept. |
@click=${fn} | An event listener, removed on dispose. |
style:--age=${() => ...} | A custom property set through the CSS object model, so it works under a strict CSP that blocks style="". |
An attribute value must be exactly one expression. Text mixed with ${} inside an attribute is an error with a message that says so. When a reactive child function runs again, its region is cleared and drawn again; reusing rows by key is what xo-each does in <xo-view>.
XoElement
XoElement is the base class of every xoong element. static props lists typed properties; each becomes an observed attribute (maxItems is max-items) and a signal.
| Type | From the attribute |
|---|---|
String | its text, "" when absent |
Number | Number(text), 0 when absent |
Boolean | true when present, unless it is "false" |
- Reading
this.nameinside a template function or an effect is tracked, so the right nodes update when the attribute changes. The flow is one way: an attribute sets the property; setting the property from code does not write the attribute back. A property set before the element was upgraded is kept. - Implement
render()only when the element owns its content: it is drawn into the element, replacing the children. To enhance HTML that is already there (server-rendered, or written by you), implementconnected()instead.ready()resolves when the parser has reached the end of the document, for children that may not exist yet. this.effect(fn)is an effect that is disposed when the element is removed.emit(name, detail)fires a bubbling, composedCustomEvent.define(tag, class)registers the element (once; calling it again is harmless) and installs the property accessors.
import { XoElement, html, define } from '@xoong/core';
class UserCard extends XoElement {
static props = { name: String, age: Number, vip: Boolean }; // attributes name, age, vip
render() { // this element owns its content
return html`
<h3 class=${() => (this.vip ? 'vip' : '')}>${() => this.name}</h3>
<p ?hidden=${() => !this.age}>Age ${() => this.age}</p>
<button @click=${() => this.emit('card-open', { name: this.name })}>Open</button>
<div style:--age=${() => this.age}></div>`;
}
}
define('user-card', UserCard);XoControl
XoControl extends XoElement for things that act like a form field: the rich text editor, the combobox, a tag input, your own image picker. It makes the element a form-associated custom element through ElementInternals, so it behaves like a native input in a <form>.
- Value.
valuemay be anything;serialize(value)says what the form submits (a string, a file,FormData, or nothing). It appears inFormDataand resets with the form. - Two ways to set it.
setValue(v)(and thevalueproperty) is likeinput.value = x: no events, and the form model is not told.userInput(v)is for the person's change: it sets the value and firesinput, which is what the form model listens for.userChange()fireschange. - State as signals:
dirty(against the initial value),pristine,touched(focus left the control),valid. They are mirrored as the attributesdata-dirty,data-touchedanddata-invalid, so CSS can use them.markPristine()makes the current value the new baseline. - Validation.
required, yourvalidate(value)hook (return a message, or""),setCustomValidity(),checkValidity()andreportValidity(), passed on to the browser throughinternals.setValidity. A disabledfieldsetdisables it. - Where
ElementInternalsis missing (older DOMs, happy-dom) the control keeps its own value, validity and state; it only does not appear in a nativeFormData.<xo-form>and the form binder read controls through these properties, so tests in happy-dom work.
import { XoControl, html, define } from '@xoong/core';
class StarRating extends XoControl {
static props = { ...XoControl.props, max: Number };
render() {
return html`<button type="button" @click=${() => this.userInput(Number(this.value) + 1)}>+</button>`;
}
validate(value) { return Number(value) > this.max ? 'Too many stars' : ''; } // '' means valid
}
define('star-rating', StarRating);Why Light DOM, and why no virtual DOM
- Light DOM. Elements draw into the ordinary document. Your global CSS and tokens reach them;
querySelector, axe and analytics see what the person sees; a form control participates in forms without a bridge. The cost is that collisions are possible: xoong answers with a tag prefix, cascade layers andxoong check, not walls. See Styling. - No virtual DOM. A binding points at one node. When a signal changes, the effects that read it write that node: there is no second tree to build and compare. So an update touches only the node that reads the signal and leaves the rest of the page alone, including content that you or the person put there.
- Same markup on the server. Because the DOM is the output, prerendering produces plain HTML and the browser needs no hydration step. See Build and delivery.
How xo-view expands your markup
<xo-view> is the one element that reads markup as a language. It takes its children as a template, draws a copy, and binds the copy.
The markup is kept as a template and drawn from it, so a view that is disconnected and connected again starts from the same source.
<xo-view get="listUsers">
<p xo-if="$loading">Loading…</p>
<li xo-each="items"><a href="/users/{id}">{name}</a></li>
</xo-view><xo-view get="listUsers">
<!--xo-if-->
<!--xo-each-->
<li><a href="/users/1">Ada</a></li>
<li><a href="/users/2">Cy</a></li>
<!--/xo-each-->
</xo-view>- Text. A text node with
{path}gets one effect that sets itsdata. It is a text node, never HTML, so the data cannot inject markup. Missing values are empty; objects are shown as JSON;{publishedAt | datetime}formats withIntl. - Attributes. An attribute with
{path}gets an effect that callssetAttribute. Interpolating intoon*orsrcdocis refused (the attribute is removed and a message is logged). In a URL attribute (href,src,action, ...) any scheme other than relative,http,https,mailtoortelbecomesabout:blank, even when hidden with whitespace. xo-each. The element is the row template, bracketed by two comment markers. Rows are keyed by the entity reference (User#1) or, for plain values, by position, so a row is reused when its item moves and removed (its effects disposed) when its item goes. A deleted entity drops out. Inside a row,$index,$n(from 1) and$i0,$i1(one per nesting level) are available.xo-if. A comment marker stands where the element would be; the element is created and bound when the condition becomes true, and unbound and removed when it becomes false.xo-if="!path"negates, an empty array is false, andxo-if="type=image"orstatus!=draftcompares with a literal.- Scope.
{name}is looked up from the current item outwards to the view's root and itsp-*params, so{id}in a row finds the row'sidfirst.{.}is the item itself,$loading,$errorand the like are variables of the view. - Template tags.
<xo-table>and<xo-form>are not elements that draw themselves. They are expanders: before binding, each is replaced by the plain markup it describes, which is then bound like your own. If one throws (an unknown operation), a comment records it,xo-errorfires, and the rest of the view still works. - Actions (
xo-click,xo-submit,xo-param,xo-refresh,xo-add, ...) add event listeners on the element; see Data flow. - Starting again. The markup is kept; every start clones it. A view that is disconnected and connected again starts from the same source, and a prerendered view, which keeps its original markup in a
<template data-xo-template>, starts from that instead of its rendered rows.
Where to look in the code
packages/core/src/signals.js: signals, computed, effects, batchpackages/core/src/html.js: template parsing and bindingspackages/core/src/element.js:XoElement,definepackages/core/src/control.js:XoControlpackages/data/src/view.js:<xo-view>:bindNode,bindEach,bindIf, the scope lookupdocs/decisions/0002-light-dom-only.md: why Light DOM