[]previous and next page

Fine-grained rendering

What the compiler turns JSX into (template, insert, effect, delegated events), why no virtual DOM exists, and why the live DOM contains no comment nodes.

For agentsUpdated

A ShadowJS component returns real DOM nodes, built once. The compiler (@shadow-js/compiler, used by @shadow-js/vite) turns each JSX tree into three things:

  1. a template: the static HTML of the tree, parsed once and cloned per instance;
  2. node references: plain firstChild / nextSibling walks from the cloned root to the nodes that need work, with no querySelector and no markers;
  3. bindings: one insert per dynamic child, one effect per group of dynamic attributes, one assignment per event handler.

When a signal changes, the binding that read it re-runs and updates exactly one text node, attribute or region. Nothing is diffed except arrays inside a single insert (see reconciliation).

The helpers the compiler emits are public exports of @shadow-js/web, listed in docs/contracts/dom-runtime.md: template, insert, effect, anchor, createComponent, spread, delegateEvents, setAttribute, className, derived.

What the compiler emits#

The examples below call compile() from @shadow-js/compiler and compare its real output with the literal shown. They share one helper.

src/compiled.tsts
import { compile } from "@shadow-js/compiler";

/** Compiles JSX source for the browser ("dom") or the server ("ssr") and trims the result. */
export function compiled(source: string, generate: "dom" | "ssr"): string {
  return compile(source, { filename: "example.tsx", generate }).code.trim();
}

Static markup becomes a template, a dynamic child becomes insert#

src/greeting-dom.tsts
import { compiled } from "./compiled";

const source = `export function Greeting(props: { name: string }) {
  return <h1 class="title">Hello, {props.name}</h1>;
}`;

const expected = String.raw`import { insert as _$insert, template as _$template } from "@shadow-js/web";
const _tmpl$ = /*#__PURE__*/ _$template("<h1 class=\"title\">Hello, </h1>");
export function Greeting(props: { name: string }) {
  {
const _el$ = _tmpl$();
_$insert(_el$, () => props.name);
return _el$;
}
}`;

console.log(compiled(source, "dom") === expected);
Expected output
true

class="title" and the text Hello, live in the template string and are never touched again. {props.name} becomes insert(parent, () => props.name): the arrow function is an accessor, and insert runs it inside a render effect, so a change of props.name updates one text node. The component function itself runs once.

Signals, attributes and events#

src/counter-dom.tsts
import { compiled } from "./compiled";

const source = `import { useStore } from "@shadow-js/core";
export function Counter() {
  const [count, setCount] = useStore(0);
  return <button type="button" class={count() > 2 ? "hot" : "cold"} onClick={() => setCount(count() + 1)}>{count()}</button>;
}`;

const expected = String.raw`import { className as _$className, effect as _$effect, insert as _$insert, template as _$template, delegateEvents as _$delegateEvents } from "@shadow-js/web";
const _tmpl$ = /*#__PURE__*/ _$template("<button type=\"button\"></button>");
import { useSignal as useShadowSignal } from "@shadow-js/core";
import { useStore } from "@shadow-js/core";
export function Counter() {
  const [count, setCount] = useShadowSignal(0);
  {
const _el$ = _tmpl$();
_$effect(() => {
_$className(_el$, count() > 2 ? "hot" : "cold");
});
_el$.$$click = () => setCount(count() + 1);
_$insert(_el$, count);
return _el$;
}
}
_$delegateEvents(["click"]);`;

console.log(compiled(source, "dom") === expected);
Expected output
true

Read it top to bottom:

  • type="button" is static, so it is in the template. class depends on count(), so it becomes an effect that calls className. That effect is the only code that re-runs when count changes for the class.
  • {count()} is compiled to the accessor count itself, passed to insert.
  • onClick becomes an assignment to the expando _el$.$$click, and the module calls delegateEvents(["click"]) once. There is no per-element listener.

Components receive getters#

Props of a component call are a plain object whose reactive entries are getters. The child reads props.title at the moment it needs it, inside whatever tracking scope it is in.

src/component-dom.tsts
import { compiled } from "./compiled";

const source = `import { Card } from "./card";
export function App(props: { title: string }) {
  return <Card title={props.title} count={3}><b>child</b></Card>;
}`;

const expected = String.raw`import { template as _$template, createComponent as _$createComponent } from "@shadow-js/web";
const _tmpl$ = /*#__PURE__*/ _$template("<b>child</b>");
import { Card } from "./card";
export function App(props: { title: string }) {
  return _$createComponent(Card, {
get title() {
return props.title;
},
count: 3,
get children() {
return _tmpl$();
}
});
}`;

console.log(compiled(source, "dom") === expected);
Expected output
true

title is a getter because it reads props; count: 3 is a plain value because it is a constant. children is a getter too, so the child nodes are created when the child component reads props.children, inside the child's owner. createComponent calls the component untracked in the current owner.

The same source for the server#

generate: "ssr" produces string-building code for @shadow-js/web/server. The hydration key and the region markers are part of the server output only:

src/greeting-ssr.tsts
import { compiled } from "./compiled";

const source = `export function Greeting(props: { name: string }) {
  return <h1 class="title">Hello, {props.name}</h1>;
}`;

const expected = String.raw`import { ssrHydrationKey as _$ssrHydrationKey, escape as _$escape, ssr as _$ssr } from "@shadow-js/web/server";
const _ssr$ = ["<h1"," class=\"title\">Hello, <!--[-->","<!--]--></h1>"];
export function Greeting(props: { name: string }) {
  return _$ssr(_ssr$, _$ssrHydrationKey(), _$escape(props.name));
}`;

console.log(compiled(source, "ssr") === expected);
Expected output
true

On the client, hydration uses the DOM output above, unchanged. See hydration keys.

Updates touch one node#

A binding owns the node it created. When its source changes, the same Text node is edited in place.

There is no virtual DOM and no re-render: no tree of the component's output exists in memory, so there is nothing to compare.

Zero comment nodes in the live DOM#

Dynamic regions are located by real sibling nodes or by empty Text nodes created by anchor() at instantiation time. The live DOM, after client rendering, after hydration and after any update, contains no comment nodes and no placeholder elements. (The server HTML does contain <!--[--> / <!--]--> markers; hydration removes them.)

Delegated events#

The compiler delegates a fixed allow-list: click dblclick input change keydown keyup keypress pointerdown pointerup pointermove mousedown mouseup mousemove focusin focusout beforeinput touchstart touchend touchmove contextmenu. For these it writes el.$$<name> = handler (and el.$$<name>Data for the tuple form onClick={[handler, data]}). delegateEvents installs one listener per event name on the document; it walks event.composedPath() from the target upward, calls each $$name it finds with this set to that element, and stops when a handler stops propagation. Every other event (onFocus, onBlur, onScroll, ...) gets a native addEventListener on the element, bound once.

Handlers run untracked, inside one batch per event, so the DOM and the effects are current when the dispatch finishes (see the scheduler). To switch behavior at run time, branch inside the handler: onClick={() => (mode() === "a" ? doA() : doB())}.

Templates and namespaces#

template(html, flags) parses its markup lazily on the first call and clones it afterward. Flag 1 wraps the markup in <svg> so the nodes get the SVG namespace, 2 does the same for MathML, 4 clones with document.importNode (custom elements). One root yields that element, several roots yield a DocumentFragment.

src/template-flags.tsts
import { template } from "@shadow-js/web";

const circle = template('<circle r="4"></circle>', 1)();
console.log(circle instanceof SVGElement);
console.log(circle.nodeName === "circle");

const card = template("<div><p>x</p></div>")();
console.log(card.nodeName);

const many = template("<i>a</i><i>b</i><i>c</i>")();
console.log(many.childNodes.length);
Expected output
true
true
DIV
3

Where the pieces go next#