Skip to content

Rendering and patches

This page traces what happens between “a machine changed” and “the DOM updated” — the render pass that registers bindings and the wire format that carries the changes.

Rendering a route runs once, synchronously, against an active render state. As the template executes, each read(), each, when, and reactive directive registers a binding on that render state and produces HTML. By the time the pass finishes, the server holds two things: the HTML to send, and a map of every binding on the page keyed by slot id and indexed by source machine.

This pass is the only time the template runs. After it, the page is just registered bindings — there’s no template re-execution to update the DOM, only recompute and patching.

Bindings address the DOM two ways:

  • Slot ids mark a position in content (the text a read() controls). List and branch bodies push a scope prefix, so a slot inside the third cart line has an id scoped to that line — ids stay stable and unambiguous as lists grow.
  • Element ids mark a specific element (the target of an attribute patch).

The allocation is deterministic within a render so the same binding gets the same address on every pass.

A patch is two orthogonal parts: a target (where) and an op (what to do there).

type Patch =
| { target: { kind: 'slot'; id: string }; op: 'text'; value: string }
| { target: { kind: 'slot'; id: string }; op: 'html'; value: string }
| { target: { kind: 'element'; id: string }; op: 'attr'; name: string; value: string }

Targets and ops are independent dimensions, which is what keeps the format small and extensible: new capabilities are new ops or new targets, not a new message type.

insert / remove / move on slot targets are the keyed-list ops: emitted from a server-side diff, applied sequentially against the list’s element children by index. An unkeyed list still re-renders its body via one html patch.

When a list or branch body is replaced wholesale with an html patch, that new HTML already contains the fresh values of every binding inside it. Any finer-grained text/attr patches whose slots live inside that scope are therefore redundant — and applying them afterward would either no-op or hit the wrong element. The recompute pass detects this and drops the subsumed descendant patches, sending only the one html patch. It’s the difference between a correct update and a flicker of stale-then-fresh.

On the client, a tiny applier dispatches each patch on target.kind then op and performs the corresponding native DOM operation — set text, swap inner HTML, set or remove an attribute. There’s no framework runtime reconciling a tree; it’s direct DOM mutation. An unknown op is logged and skipped rather than throwing, so a newer server speaking a not-yet-supported op degrades gracefully.

The same patch shape travels over both transports. A POST event response and an SSE push carry identical patch lists — the recompute pass doesn’t know or care which one is delivering its output. That’s why making a route live changes nothing about how it renders: only the channel differs.

When patches are valid: the divergence contract

Section titled “When patches are valid: the divergence contract”

A patch is a diff against server truth — applying it assumes your DOM matches that truth. Live pages (// @stator live) get this guaranteed: an initial sync converges the page on connect, fan-out keeps it converged, and the dispatching tab’s own connection is skipped (its POST response already carried the diff). Non-live pages hold the guarantee only under two conditions:

  • one tab per session per page — a second tab’s dispatches update the server (and the first tab’s next dispatch works from that truth), but the first tab’s DOM won’t hear about them until navigation;
  • no shared (app-machine) reads — another visitor’s changes to shared state reach a non-live page the same way: not until navigation.

Outside that contract the framework fails safe, not silent: slot ids are scoped by branch arm and by list key, so a patch computed for content your page isn’t showing can never write into the wrong element — it’s skipped with a console warning. If a page reads shared state or expects multi-tab coherence, declare it live.

Client code has one obligation: never remove server-owned DOM. Islands hide and show it (CSS classes, hidden) — the slots must stay in the document to receive patches. An island that deletes server-rendered nodes orphans them until the next navigation.