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.
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:
- a template: the static HTML of the tree, parsed once and cloned per instance;
- node references: plain
firstChild/nextSiblingwalks from the cloned root to the nodes that need work, with noquerySelectorand no markers; - bindings: one
insertper dynamic child, oneeffectper 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.
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#
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);
trueclass="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#
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);
trueRead it top to bottom:
type="button"is static, so it is in the template.classdepends oncount(), so it becomes aneffectthat callsclassName. That effect is the only code that re-runs whencountchanges for the class.{count()}is compiled to the accessorcountitself, passed toinsert.onClickbecomes an assignment to the expando_el$.$$click, and the module callsdelegateEvents(["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.
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);
truetitle 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:
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);
trueOn 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.
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);
true
true
DIV
3Where the pieces go next#
- Arrays inside
insert,ForandIndex: reconciliation. - Server output and keys: hydration keys.
- Which constants the compiler keeps live: forgiving reactivity.
- Compiler diagnostics (
NAMESPACED_TAG,STRING_EVENT_HANDLER, ...): diagnostics.