xoong
Menu · Rendering
DocsArchitectureRendering

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.

How a signal change reaches the DOM Two signals, first and last, feed a computed value, full, which is calculated lazily and cached. Two effects read the computed value: one writes a text node and one writes the class attribute. Each effect updates only its own DOM node, which is on the screen the person sees: a window with a heading. A separate box notes that batch groups several writes so that the effects run once, at the end. To the right of the screen, the code that makes it, in a window named card.js. The code window shows card.js: const full = computed(() => first.value + ' ' + last.value); html`<h3 class=${() => full.value.length > 12 ? 'long' : ''}> ${() => full.value}</h3>`;. Steps: 1. Your code writes first.value = "Augusta". The computed value full read first, so it is marked as out of date. 2. The two effects that read full run. full is calculated now, once, and cached. 3. Each effect writes its own DOM node: the text of the heading becomes "Augusta Lovelace", and, because that is longer than 12 characters, the class becomes "long". Nothing else on the page is touched. 4. Your code writes last.value = "King". full is marked as out of date again. 5. The two effects run again. full is calculated again, once. 6. Each effect writes its own node again: the text becomes "Augusta King", and the class is empty again. When the animation plays, 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. firstsignal("Ada") lastsignal("Lovelace") fullcomputed(): lazy, cached effectsets node.data effectsets class attribute batch(fn)groups writes: effectsrun once at the end 1 4 2, 5 3, 6

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.

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 it

Templates

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 writeIt does
${() => x.value} as a childAn 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.

TypeFrom the attribute
Stringits text, "" when absent
NumberNumber(text), 0 when absent
Booleantrue when present, unless it is "false"
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>.

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

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 life of an xo-view Six steps, top to bottom. 1. connected: the view waits until the page is parsed. 2. start: it saves its markup once and reads its p-* params. 3. draw: it clones the markup and binds every node. 4. select the query: an effect over get and the params releases the old query and retains the new one. 5. live: when params, the store or the query status change, only the bound nodes update. 6. disconnected: the effects are disposed, the query is released and the stream is closed. connectedwaits until the page is parsed startsaves the markup once, reads p-* params drawclones the markup, binds every node select the queryeffect over get + params: release, retain livea change updates only the bound nodes disconnectedeffects disposed, query released, stream closed

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>

Where to look in the code