Hydration #
WebUI renders components reached by the initial request on the server. JavaScript is optional:
| Component files | Browser behavior |
|---|---|
user-card.html + user-card.ts or user-card.js | The authored class owns events, lifecycle, reactive state, and imperative APIs |
user-card.html only | The server-rendered HTML stays inactive unless later navigation or state changes require browser rendering |
This keeps first-page work small without requiring empty TypeScript classes.
HTML-Only Components #
HTML-only components can use bindings, attributes, <if>, and <for>. Their
initial server-rendered DOM needs no hydration work or browser state.
When the framework is loaded, it can later activate the compiled template for soft navigation or a browser-applied state update. Client-created instances mount immediately. Existing repeated content remains in place until its collection is explicitly supplied; supplying an empty array removes it.
An app that remains static after SSR does not need the framework. An app that
wants HTML-only soft navigation or browser-applied template updates imports
@microsoft/webui-framework once in its browser entry.
Authored Components #
Add a sibling module only when the component owns browser behavior:
import { WebUIElement } from '@microsoft/webui-framework';
export class UserCard extends WebUIElement {
// Events, lifecycle, decorators, or imperative APIs belong here.
}
UserCard.define('user-card');
Only @observable and @attr fields are eligible for exact initial state
projection. Ordinary template values already exist in the rendered HTML and do
not need to enter browser state just because the component has an event handler,
w-ref, lifecycle method, or imperative API.
An authored component with no decorators can therefore wire its behavior without adding any startup state.
For a normal buffered page, load authored component definitions with a
parser-inserted, non-async ES module script or a classic defer script. If a
classic script blocks parsing, place it after every SSR instance it may upgrade.
This guarantees that each component subtree exists before upgrade. WebUI then
hydrates synchronously inside super.connectedCallback(); when it returns,
bindings, events, and w-ref references are ready. Progressive streaming pages
use the early async module contract described below instead.
Until a containing WebUI component hydrates, descendants must not insert, remove, or reorder nodes in its SSR subtree. Hydration numbers the trusted server DOM to match the compiled template and cannot recover once that numbering shifts.
Components using @event must be authored because the compiler needs a real
handler implementation. Do not add an empty class merely to make template
bindings or routing work.
Deferred Hydration Lifecycle #
Deferred policies keep the trusted SSR subtree in the DOM. They change when browser rendering work and component bindings/listeners activate; they do not defer HTML parsing, DOM construction, custom-element definition, or resource discovery.
- Visibility policies activate within the viewport lead or immediately before
interaction. Chromium uses native content-relevance events when available;
other browsers use the shared
IntersectionObserver. - Interaction policy waits for pointer, focus, keyboard, or click intent and can keep the component module graph out of startup JavaScript and heap.
- Client-created components, successfully hydrated reconnects, and eager instance overrides mount eagerly.
hydratedCallback()is the cross-policy signal for work that requires bindings orw-refvalues.- Missing optional coordinator/browser support falls back to eager hydration.
See the Lazy Component Policy reference for the complete policy matrix, required reservation syntax, combinations, and per-instance overrides.
Interaction-Triggered Application Hydration #
Mark the application component:
<template w-hydrate="interaction">
<nav>...</nav>
<outlet />
</template>
Then use the small router interaction entry instead of eagerly importing the component graph:
import {
installInteractionHydration,
wakeInteractionHydration,
} from '@microsoft/webui-framework/interaction-hydration.js';
import { prepareRoutePreload } from '@microsoft/webui-router/preload.js';
let prepared: ReturnType<typeof prepareRoutePreload> | undefined;
const disposeHydration = installInteractionHydration({
onError: () => prepared?.destroy(),
load: async () => {
const [, { Router }] = await Promise.all([
import('./component-definitions.js'),
import('@microsoft/webui-router'),
]);
Router.start({ preload: prepared, loaders });
},
});
try {
prepared = prepareRoutePreload({
onIntent: () => wakeInteractionHydration(),
});
} catch (error) {
disposeHydration();
throw error;
}
The compiler emits the root marker. Before hydration, mouse hover prefetches one internal route partial into a bounded raw-byte buffer without parsing templates or loading the router. Pointer-down, focus, and keyboard start the component and router imports in parallel. An eligible click waits, then replays after the router adopts the prefetched response; no duplicate route request or parse is performed.
This policy trades first-interaction latency for lower startup JS and heap, so
measure both. Prefer an eager root with w-render="lazy" descendants when
request-to-hydrated time matters. Synthetic replay cannot preserve transient
user activation or target closed-shadow controls; hydrate those paths eagerly.
For one offscreen singleton, interaction hydration can be combined with lazy rendering. The boundary hydrates immediately after its module loads rather than entering the visibility queue. See Lazy Rendering Until Interaction for syntax and usage constraints.
Non-router apps can instead use the lower-level
installInteractionHydration({ load }) entry. Its load() promise must mean all
target listeners are ready. Use isInteractionReplay(event) when ancestor
capture work must distinguish the synthetic replay.
Images in deferred components #
Visibility-deferred hydration delays JavaScript bindings, not image fetching. Use
loading="lazy", srcset, sizes, and explicit image dimensions.
An @load or @error event may fire before a deferred component hydrates. If
component state depends on it, bind the image with w-ref and reconcile its
current status in hydratedCallback():
@observable imageState = 'pending';
image!: HTMLImageElement;
protected override hydratedCallback(): void {
if (this.image.complete) this.updateImageState();
}
updateImageState(): void {
this.imageState = this.image.naturalWidth > 0 ? 'loaded' : 'error';
}
Call the same idempotent method from @load and @error.
Progressive Streaming Hydration #
WebUI can hydrate a complete child region while its reusable parent component
is still rendering. Author a
<boundary> where readiness changes:
<!-- index.html -->
<head>
<script type="module" async src="/index.js"></script>
</head>
<body>
<ntp-page></ntp-page>
</body>
<!-- ntp-page.html -->
<main>
<h1>{{title}}</h1>
<boundary name="search-ready">
<search-box query="{{query}}"></search-box>
</boundary>
<section>{{slowFeed}}</section>
</main>
Import the coordinator before registrations:
import '@microsoft/webui-framework/streaming.js';
import './ntp-page.js';
import './search-box.js';
The entry uses one <ntp-page>. When traversal reaches its internal boundary,
WebUI pauses and returns a runtime descriptor to the host. resume commits only
<search-box> through its checkpoint, so it can become interactive
immediately. advance then renders the remaining parent section, generated
span completion, and later shell bytes. This boundary-only resume means a
sibling boundary is not needed to separate the early child from the parent
tail.
Boundaries can also occur in true conditions and selected route content. A
boundary-bearing subtree reached from a <for> body fails the build with
boundary-in-repeat. A complete <for> may instead sit inside one boundary,
and boundaries before or after a <for> are valid.
Timing and lifecycle #
When a boundary pauses inside a component, WebUI generates a span around the unfinished parent. The early child is compiler-marked to bypass exactly that nearest parent barrier. Other descendants stay opaque until the parent span completes. The same bounded traversal works in light DOM and across open shadow roots and slots.
When a component calls .define() before streamed template metadata arrives,
WebUI delays native definition because browsers snapshot observedAttributes
at definition time. When a checkpoint arrives first, undefined roots share one
definition waiter per tag. Parents hydrate before ordinary descendants.
Use hydratedCallback() for setup that needs bindings, events, or w-ref
references. It runs synchronously exactly once after the first successful
ordinary hydration, client mount, streamed activation, or dormant static-host
wake, including a lazy activation. connectedCallback() can run earlier on a
lazy or deferred streamed host and can run again after reconnect, so it is not a
universal post-hydration signal. Actual timing still depends on module download,
visibility, server progress, and transport delivery.
WebUI dispatches these events on window:
webui:boundary-hydratedafter each commit, only whenwindow.__WEBUI_STREAMING_DEBUG__ === true. ItsCustomEvent.detailcontains{ sequence, terminal, kind }. Sequence numbers are response order, not authored names. Keep this diagnostics flag off in production.webui:hydration-completeonce the empty terminal record has arrived and no eager component, checkpoint, generated span, definition waiter, or ancestor barrier remains pending. It means the complete response lifecycle is done, not merely that the first interactive child is ready.
Measuring commits in production #
Events require a listener installed before the first commit, which is often
impossible: the coordinator is a separate async entry, so an analytics or RUM
script can easily load after early boundaries have already hydrated. WebUI
therefore also emits a performance.mark() for every commit, with no flag and
no listener:
| Mark | Emitted when |
|---|---|
webui:boundary:<id> | A checkpoint commits |
webui:boundary:<id>:update | A projected state update is applied |
webui:span:<id> | A generated parent span completes |
webui:streaming:terminal | The terminal record settles |
Because marks sit in the performance timeline, they can be read at any later point:
const commits = performance
.getEntriesByType("mark")
.filter((entry) => entry.name.startsWith("webui:"));
Boundary <id> values are response-local occurrence IDs, not declaration IDs
or authored names. Span IDs use a separate response-local namespace.
Hydrating across several tasks #
By default the coordinator drains its queue in one pass, which reaches interactivity soonest. That assumes boundaries arrive spread across the response. If an intermediary buffers and coalesces the response, records can all arrive at once and hydrate in a single long task - exactly what streaming is meant to avoid.
Set a millisecond budget to make the coordinator yield to the browser between boundaries instead:
window.__WEBUI_STREAMING_SLICE_MS__ = 5;
Set it before the application entry runs. It trades total hydration time and the last boundary's interactivity for responsiveness during hydration, so leave it unset unless you have measured a long task. Record order and every correctness guarantee are unchanged.
After a checkpoint commits, WebUI removes its generated payload, sentinel, markers, and temporary attributes. Final occurrences release roots immediately. Updatable occurrences retain only live roots and a pending shallow patch until terminal.
The browser protocol is the single unversioned
[sequence, kind, target, payload] contract. Kinds are final
checkpoint, updatable checkpoint, update, generated span completion, and
terminal. A malformed, truncated, out-of-order, or over-limit stream fails
closed, suppresses successful completion, and releases discoverable deferred
state within fixed bounds.
Range records normally carry their projected state. When the exact preceding
range projection is a proven subset under the same server state revision, a
record instead carries stateRef plus only its top-level stateDelta. The
coordinator resolves the state before activation and keeps prior state immutable
for delayed roots. Missing, stale, forward, mismatched, or malformed references
fail closed. State-update records remain independent patches and never alter
the range-state reference base.
At body_end, the handler emits one markerless empty terminal envelope:
[nextSequence,4,0,{}]. Its flush also commits any preceding native or static
tail HTML, but terminal records never repeat template metadata or state. A
truncated or malformed stream, or one exceeding a client work bound such as the
queued-boundary or marker-scan limit, logs an error, suppresses
webui:hydration-complete, and releases discoverable deferred state within
fixed bounds. Valid commits perform no document-wide scan; a bounded sweep is a
fatal-cleanup fallback only.
The client trusts records past two checks, because the same WebUI release wrote
them: JSON.parse (which alone detects any truncation, since a cut-off record
is never valid JSON) and the four-element tuple shape. Everything else is
enforced where it is actually knowable - a sequence or boundary-target mismatch
halts the stream, and a defective payload fails the commit closed. There is no
compatibility parser; obsolete tuple shapes are rejected.
CSP and delivery #
Pass the request nonce with RenderOptions::with_nonce. The handler applies it
to generated inline boundary scripts, while your Content Security Policy must
also allow the external application module. Use a fresh nonce per response.
FlushWriter::flush means that WebUI handed all currently buffered bytes to the
HTTP transport. A server adapter, compression layer, CDN, or reverse proxy can
still buffer those bytes. Disable response buffering where appropriate and
verify early delivery through the production path.
Updates #
Commit an occurrence as updatable only when complete SSR markup should become
interactive before its slow state resolves. update(instanceId, patch) applies
projected state through normal reactivity. It never inserts or replaces markup
and never reruns hydration. If the class or parent barrier is still pending,
WebUI queues one shallow patch and applies it after successful activation.
Build-Time State Projection #
Exact state projection is opt-in. Rust does not inspect JavaScript or
TypeScript. The application bundles its browser code first, and a bundler
adapter emits webui-projection.json from the same resolved graph and output
membership that produced the browser chunks.
The projection compiler contract is bundler-neutral. The
@microsoft/webui/projection.js subpath currently includes the supported
esbuild adapter:
npm install -D esbuild typescript
// build-client.mjs
import * as esbuild from 'esbuild';
import { esbuildProjection } from '@microsoft/webui/projection.js';
await esbuild.build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
splitting: true,
format: 'esm',
plugins: [esbuildProjection()],
});
Run the client build once, then give its manifest to WebUI:
node build-client.mjs
webui build ./src \
--plugin=webui \
--projection-manifest ./dist/webui-projection.json \
--out ./dist
The generated file has this shape (hashes abbreviated):
{
"schema": "webui.state-projection/v1",
"producer": {
"name": "@microsoft/webui/projection.js",
"version": "0.0.18"
},
"adapter": {
"name": "esbuild",
"bundler": "esbuild@0.28.1"
},
"root": "..",
"analysisHash": "sha256:...",
"buildId": "sha256:...",
"outputs": {
"dist/index.js": "sha256:..."
},
"inputs": {
"src/user-card.ts": "sha256:..."
},
"components": {
"user-card": {
"module": "src/user-card.ts",
"outputs": ["dist/index.js"],
"hydrationKeys": ["displayName", "selected"],
"navigationKeys": ["displayName", "selected"]
}
}
}
Do not hand-author this file. It is a deterministic record of the completed bundle and becomes stale as soon as a declared input or output changes.
The manifest records exact analyzable-source hashes, emitted output hashes,
code-split membership, component ownership, and sorted @observable plus
@attr property names. Non-source inputs remain in esbuild's native graph but
stay outside the normalized projection graph; their emitted outputs provide the
byte proof instead of a duplicate input hash. WebUI validates the declared
hashes and embeds only the resulting key surfaces in
protocol.bin. Runtime handlers do not load the manifest, TypeScript, or a
bundler.
Behavior is intentionally strict:
- With no manifest, the build remains correct and sends full state. Projection is disabled rather than guessed.
- Once any manifest is supplied, every scripted component compiled into the
protocol must have exactly one entry. Missing coverage fails with
PROJ-B001. - Shared controls supplied through
--componentsremain application-owned bundles. If they are external to the main bundle, build them separately and pass each manifest fragment with another--projection-manifest. - Stale source inputs or outputs fail the WebUI build. Re-run the client bundler before rebuilding the protocol.
@attrentries use JavaScript property names. During hydration, an existing SSR host attribute wins; projected state seeds the property only when that attribute is absent.
The adapter runs inside the application's existing esbuild invocation. It does not start a second bundler run, and it does not constrain chunking, dynamic imports, external modules, or output naming.
Other bundlers are not coupled to esbuild. A Vite, Rollup, Rolldown, webpack,
Rspack, or other adapter can construct the exported AdapterContext, call
compileProjection(), and run the exported conformance suite. The official
package currently ships and supports the esbuild adapter. Custom adapters must
provide a normalized JavaScript/TypeScript source graph and exact
emitted-output bytes. Non-source bundler inputs stay outside the semantic
graph, so projection does not decode or hash their input bodies.
State Sent to the Browser #
With validated projection manifests, the initial page includes only
@observable and @attr values needed by authored components on the active
route. Template values used only for server rendering stay out of browser
state. Without manifests, WebUI preserves full state for compatibility and
correctness.
Later soft navigations include the values needed to render the destination components. Inactive sibling routes do not enlarge either payload. If the initial page needs no client state, WebUI writes:
{"state":{}}
State sent to the browser is client-visible. Never place credentials, private tokens, or other secrets in it.
Routing #
The router and framework can mount HTML-only routes from compiled templates without empty component classes. If the framework is not loaded and no authored custom element owns the destination tag, navigation falls back to a full page request.