How @microsoft/webui-framework actually turns server-rendered HTML into a live, reactive DOM, and what it does on every keystroke after that.
This document is for framework contributors, plugin authors, and anyone debugging hydration. If you just want to author components, read README.md and the Interactivity guide instead.
WebUI is built on a hard rule: the server emits HTML, the browser parses HTML, and the framework adopts that HTML in place. Nothing is re-rendered. No virtual DOM, no diff against a fresh tree, no innerHTML = ... to swap content. To make that work without DOM annotations on every dynamic node, the framework leans on:
- compiled template metadata (path indices, not selectors),
- five lightweight HTML comment markers around structural blocks,
- a parallel walk of the SSR DOM and the parsed template DOM to keep ordinals aligned,
- a per-component path index so reactive updates touch only the bindings that actually depend on a changed property.
The rest of this document explains each of those pieces, in the order the runtime executes them.
Build time Server render Client hydration
────────────── ──────────────── ──────────────────
Parse templates → Render with state → Framework adopts
Compile metadata Inject SSR markers existing DOM,
Emit Declarative wires bindings,
Shadow DOM strips markers
Emit webui-data O(affected) updates
- Server renders HTML. The handler walks compiled template metadata and application state and emits Declarative Shadow DOM (or light DOM) with five comment markers around structural blocks, plus an inert
#webui-datablock carrying state and per-component template metadata. - Browser parses HTML. The parser creates shadow roots inline. The user sees a fully painted page before any framework code runs.
- JavaScript loads. The component class registers via
customElements.define. The browser upgrades pre-existing tags and firesconnectedCallback. $mountdecides client-or-SSR. If a shadow root exists or the element already has children, the framework treats the DOM as SSR. Otherwise it parses the static template HTML (meta.h) into a detached staging root, upgrades custom elements, wires bindings, applies the first binding pass, and only then appends the nodes. ChildconnectedCallbackmethods see initial parent:property bindings.$applySSRStateseeds observables. Backing fields (_count,_title, ...) are written directly fromwindow.__webui.stateso reactive bindings observe values that match the painted DOM.$hydratewalks the DOM once. Text, attribute, conditional, repeat, and event bindings are resolved by a single in-order pass that uses path indices plus marker-aware ordinal traversal.- Stale markers are removed. Item markers (
<!--wi-->) and closing markers (<!--/wc-->,<!--/wr-->) are deleted; start markers (<!--wc-->,<!--wr-->) stay as anchors for runtime updates. - Path index is built lazily on the first reactive change. Subsequent updates are O(affected bindings).
There is no flash of content, because the HTML was already on screen at step 2. There is no first render, because the framework never re-renders the DOM that SSR emitted.
The handler emits exactly five comment markers, all defined in src/element/markers.ts:
| Marker | Meaning |
|---|---|
<!--wr--> |
Repeat block start (one per <for>) |
<!--/wr--> |
Repeat block end |
<!--wi--> |
Repeat item boundary (one per iteration) |
<!--wc--> |
Conditional block start (one per <if>) |
<!--/wc--> |
Conditional block end |
Text bindings, attribute bindings, and event handlers are not marked. They are located via compiled path indices.
Blocks change cardinality. A <for> produces zero, one, or many child runs. An <if> may render its content or not. The compiled path indices in meta.h describe the static skeleton, so the framework cannot derive "where does this block live in the SSR DOM" from path indices alone. The markers make that boundary explicit.
Static-position bindings (text, attributes, events) do not have this problem. Their position relative to the static skeleton is fixed at compile time, so a path index plus a marker-aware ordinal walk is enough.
Template:
<h1>{{title}}</h1>
<button @click="{toggle()}">Toggle</button>
<if condition="visible">
<p>Now you see me</p>
</if>
<for each="item in items">
<span data-id="{{item.id}}">{{item.name}}</span>
</for>Server output:
<template shadowrootmode="open">
<h1>My List</h1>
<button>Toggle</button>
<!--wc--><p>Now you see me</p><!--/wc-->
<!--wr-->
<!--wi--><span data-id="1">Alice</span>
<!--wi--><span data-id="2">Bob</span>
<!--/wr-->
</template>Notice that there are no markers on <h1>, <button>, or the text inside <span>. Path indices reach those.
<!--/wc-->, <!--/wr-->, and <!--wi--> must remain in the DOM for the entire hydration pass, because the ordinal-traversal algorithm uses marker pairs to skip block content when counting siblings. Removing a closing marker mid-pass corrupts later resolution calls. The framework collects them into a staleMarkers array and deletes them after $finalize (events + refs).
<!--wc--> and <!--wr--> start markers are kept after hydration as runtime anchors. They are the insertion points used when the condition flips or the repeat collection grows.
Hydration assumes SSR DOM, marker comments, and compiled metadata come from the same trusted WebUI compiler/handler version. Hand-edited marker streams are unsupported; every <!--wr--> and <!--wc--> must have its matching closing marker.
The compiler emits one JSON-safe TemplateMeta per component plus a small component-local condition closure array. During SSR, all non-executable metadata is delivered in <script type="application/json" id="webui-data">; during SPA partial navigation, the router registers the metadata object directly and executes only the closure arrays.
{
"inventory": "01",
"state": { "items": [] },
"templates": {
"todo-app": {
"h": "<div class=\"todo\"><ul></ul></div>",
"tx": [],
"a": [],
"ag": [],
"c": [[[0, ["items.length"]], 0, [[], 0]]],
"r": [["items", "item", 1, [[0], 0]]],
"e": [],
"b": [],
"sa": "todo-app",
"sd": 1,
"re": []
}
}
}The matching executable payload is stored under window.__webui.templateFns['todo-app'], for example [function(v,s){return !!v("items.length",s)}]. The framework normalizes [functionIndex, paths] condition references into direct [fn, paths] tuples once before hydration.
| Field | Purpose |
|---|---|
h |
Static HTML, marker-free, used for client-created cloning. Never has SSR markers. |
tx |
Text-binding runs, slot path + parts. |
a / ag |
Attribute bindings and the elements they target. |
c |
Conditional blocks with [conditionRef, blockIndex, slot]. |
r |
Repeat blocks with [collection, itemVar, blockIndex, slot]. |
e |
Event bindings with handler argument specs and target paths. |
b |
Nested block table (sub-templates for conditional/repeat bodies). |
sa |
Adopted-stylesheet specifier (CSS module). |
sd |
Truthy when client-created instances should attach a shadow root. |
re |
Root-level host events (attached to the host element, not the shadow root). |
The same metadata serves both paths:
- SSR hydration reads paths to compute ordinals, which are then translated against the live SSR DOM.
- Client-created creation clones
hinto a detached staging root, upgrades custom elements, walks paths directly, and applies initial bindings before the staged nodes are appended to the connected DOM.
Conditions are stored in JSON as [functionIndex, paths]. functionIndex
points into window.__webui.templateFns[tagName], and paths drives the
reactive path index. The framework normalizes this once to [fn, paths]
before hydration or client-created wiring.
// Metadata
[0, ['visible']]
// Function table
[function(v, s) { return !!v('visible', s); }]The DOM was cloned from meta.h, so child-node indices line up. Resolution is a flat index walk:
let cur: Node = root;
for (const idx of path) {
cur = cur.childNodes[idx]; // path = [1, 0] → root.childNodes[1].childNodes[0]
}
return cur;The SSR DOM contains extra content the static template does not, specifically the rendered bodies of <if> and <for> blocks delimited by markers. Naive child-index walking would land on the wrong node after the first block.
$resolveSSR walks the SSR DOM and the parsed template DOM in parallel. At each step:
- Look up the next template-side child's
nodeType(element vs text) and its ordinal among same-type siblings in the template. This lookup is cached per-template-node in aWeakMapto avoid recounting. - Call
findByOrdinal(ssrParent, nodeType, ordinal), which walks SSR siblings, skips entire<!--wc-->...<!--/wc-->and<!--wr-->...<!--/wr-->ranges (with depth tracking for nested blocks), and returns the Nth element-or-text of the requested type.
This is why closing markers must survive the whole hydration pass: they delimit the regions to skip.
A specialized variant of $resolveSSR for text bindings. The compiler emits text-slot positions as [parentPath, beforeIndex] where beforeIndex is the static template's child index. $findSSRText walks SSR text-node ordinals up to that index, again skipping marker ranges.
getTplOrdinals(tplNode) returns a Map<childIndex, [nodeType, ordinal]> cached in a WeakMap keyed by the template-DOM node. The map is built once on first access and reused for every binding inside that block.
This avoids quadratic behaviour when a block has dozens of bindings: without the cache, every binding would re-walk the parent's children to count element vs text ordinals. With the cache, each parent is walked once per block lifetime.
When the server renders <span>42</span> for @observable count = 0, the JS class default is still 0. If the framework called $update() immediately, it would overwrite 42 with 0.
$applySSRState runs before any binding is wired:
- Read
window.__webui.state(loaded lazily from the handler-emitted#webui-datablock). - Look up the component's
@observableproperty names via the decorator registry. - For each key in state that matches an observable name, write directly to the backing field:
this._count = 42. Not through the setter, so no reactive update fires.
After this step, this.count === 42 matches the rendered DOM, and the subsequent hydration walk wires bindings without disturbing the painted output.
Properties not present in state, or not on the observable list, are left at their class defaults.
After hydration, every dynamic value is connected to a direct DOM-node reference inside a binding object. There is no virtual DOM, no querySelector, and no diffing.
$buildPathIndex (called lazily on the first $update) walks every binding in the component and groups them by the observable property names they depend on:
'count' → { texts: [t1, t2], attrs: [], conds: [c1], repeats: [] }
'title' → { texts: [t3], attrs: [a1], conds: [], repeats: [] }
'*' → { texts: [...], attrs: [...], conds: [...], repeats: [...] } // volatile/computed
The wildcard ('*') bucket holds bindings whose expressions reference a path the framework cannot pre-classify (typically computed getters). They run on every flush.
this.count = 5
→ @observable setter writes _count, calls $update('count')
→ $update queues 'count' on $dirtyPaths, schedules a microtask
→ $flush walks $dirtyPaths once, looking each path up in $pathIndex
→ for each entry, walks only that subset of bindings
→ wildcard bindings run once per flush (not per dirty path)
→ DOM is patched via direct .textContent / setAttribute / etc.
Updates are coalesced via queueMicrotask. Multiple synchronous setter calls inside a single tick produce one DOM pass.
Synchronous escape hatch. Call it when you need the DOM to reflect pending writes immediately (test code, measurement before paint, etc.).
$pathIndex.get(name)is an O(1)Maplookup.- Each binding holds a direct
Text/Elementreference resolved during hydration. No selectors run on update. - Skipping unrelated bindings means a 200-binding component pays the cost of the 3 bindings that actually depend on
count. - No tree walk, no diff, no allocation per update beyond the
Set<string>of dirty paths.
Implemented in src/element/diff.ts.
When the repeat block's root element has at least one attribute binding (e.g. <todo-item id="{{item.id}}">), the first attribute is treated as the key. On collection change:
- Build a
Map<key, existingInstance>from current items. - Walk the new collection in order. For each new item:
- If a matching key exists, reuse the existing DOM and move it into position.
- Otherwise, create a new instance from the block template.
- Anything left in the map after the walk is destroyed.
Reused instances keep their event listeners, computed state, and any focus/scroll/selection state in their DOM.
When the repeat root has no attribute bindings, items are matched by index. Excess items are destroyed; new items are appended. Cheaper but loses identity on reorder.
On initial hydration, $hydrate's repeat phase walks <!--wi--> markers to discover the rendered items, then runs $hydrate recursively on each item with a scope frame that introduces the item variable. State is already seeded from window.__webui.state, so item observables match the server-rendered DOM. The <!--wi--> markers are then collected for deletion.
The <!--wc--> start marker is the runtime anchor. On hydration:
- Evaluate the condition tuple against the resolver. If truthy and an SSR marker pair exists, recursively
$hydratethe content between the markers. - If falsy, the SSR pair already contains nothing the framework cares about. The closing marker is queued for removal; the opening marker is kept as the anchor.
On reactive flip:
false → true: clone the block template under the anchor, wire it via the client-created path, run an immediate flush.true → false: tear down the existingTemplateInstance, remove its nodes, keep the anchor.
Two flavours:
- Element events (
@click="{handler(item.id, e)}"): wired via$wireEvents. The compiled metadata emits[event, handler, argSpecs, targetPath]. Hydration resolvestargetPathto the real element, installs the listener there, and captures the active scope frame soargSpecsresolve against the same repeat item or component state at dispatch time. - Root events (
refield): attached to the host element rather than the shadow root. Used for@custom-eventon the component's<template>root.
Listener cleanup is automatic. $destroy (called from disconnectedCallback via a microtask, so repeat reconciliation moves don't trigger teardown) removes everything wired during $mount.
Three delivery modes, set by the compiler from <link> / <style> declarations in the source HTML:
| Strategy | How it works |
|---|---|
| Link | <link rel="stylesheet"> baked into meta.h. The browser fetches it normally. |
| Inline | <style> element baked into meta.h. No extra request. |
| Module | A <script type="importmap">{"imports":{"tag-name":"data:text/css,..."}}</script> block in the page payload registers the CSS as a module. The framework retrieves the same CSSStyleSheet via import(tag, { with: { type: 'css' } }) and applies it to every instance via adoptedStyleSheets (meta.sa carries the specifier). |
Module sheets are cached, so each instance pays the cost of one adoptedStyleSheets push, not a full CSS parse.
Set by the compiler via --dom flag, surfaced as meta.sd:
- Shadow DOM (
meta.sdtruthy): SSR uses Declarative Shadow DOM. Client-created instances callattachShadow({ mode: 'open' }). Slot content stays in light DOM and projects through. - Light DOM: SSR renders children directly into the host. Client-created instances populate the host's
appendChildslot. No style isolation; CSS lives globally or on the host.
$mount auto-detects:
this.shadowRootpresent → shadow DOM SSR.- Children present and
meta.sdnot set → light DOM SSR. meta.sdset, no shadow root → shadow DOM client-created (existing children become slot content).- Otherwise → light DOM client-created.
src/lifecycle.ts integrates with the Performance API:
| Mark | When |
|---|---|
webui:hydrate:total:start |
First component begins hydrating |
webui:hydrate:total:end |
Last component finishes |
Measure webui:hydrate:total |
Total wall-clock hydration time |
window.addEventListener('webui:hydration-complete', () => {
const entry = performance.getEntriesByName('webui:hydrate:total', 'measure')[0];
if (entry) console.log(`Hydration: ${entry.duration.toFixed(1)}ms`);
});The webui:hydration-complete event fires once after the last component on the page finishes. Use it to gate post-hydration logic or to ship a metric.
| Operation | Cost | Why |
|---|---|---|
| Initial hydration | O(bindings) | Single pass over compiled paths |
| Reactive update | O(affected) | Path index skips unrelated bindings |
| Conditional toggle | O(block size) | Create or destroy a block instance |
| Repeat reconciliation (keyed) | O(items) | Map lookup per item, in-place moves |
| Repeat reconciliation (sequential) | O(items) | Linear scan, append/remove tail |
| Event wiring | O(events) | One-time during hydration |
- No virtual DOM, no tree copy, no diff algorithm.
- No
innerHTMLon updates. OnlytextContentandsetAttribute. - No
querySelectoron updates. All node references are pre-resolved. - No recursion in hot paths. Conditions evaluate on an explicit stack.
- No runtime template parsing. The compiler does all syntax work ahead of time.
src/
├── element.ts Orchestrator: $mount, $hydrate, $wire,
│ $resolve, $resolveSSR, $update, events,
│ teardown, path index
├── element/
│ ├── markers.ts Marker constants, collectItemMarkers,
│ │ findByOrdinal (block-skipping ordinal walk)
│ ├── diff.ts syncRepeat: keyed + sequential reconciliation
│ ├── styles.ts injectModuleStyle (adopted CSS modules)
│ └── types.ts AttrBinding, CondBinding, RepeatBinding,
│ TextBinding, ScopeFrame, TemplateInstance
├── decorators.ts @observable, @attr, attribute name registry,
│ toKebabCase fast path
├── template.ts TemplateMeta types + getTemplate registry
├── lifecycle.ts Hydration timing, hydration-complete event
└── index.ts Public surface
Public exports:
export { WebUIElement } from './element.js';
export { observable, attr } from './decorators.js';
export { getTemplate, type TemplateMeta } from './template.js';
export { hydrationStart, hydrationEnd } from './lifecycle.js';Everything else is internal and may change without notice.
- Performance:
performance.getEntriesByName('webui:hydrate:total', 'measure')afterwebui:hydration-complete. - Per-component lifecycle: instrument
connectedCallback/disconnectedCallbackon a subclass. - Marker layout: View Source on the SSR HTML. The five comment markers should be balanced; mismatched pairs almost always indicate a handler-plugin bug.
- "Template metadata not found":
window.__webui.templateswas not populated from#webui-dataor partial-response template registration. Check the build output. - A binding that does not update: confirm the property is
@observable(not just a class field) and the path appears in the template. Check$pathIndexafter the first update if you can attach a debugger.
examples/app/todo-webui— minimal SSR + interactivity exampleexamples/app/contact-book-manager— repeat blocks, keyed reconciliationexamples/app/commerce— larger composition, multiple components per page- Interactivity guide — component-author view of the same machinery