Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2757,6 +2757,17 @@ WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled m
`@microsoft/webui-framework` consumes the metadata object above plus the SSR markers emitted by `WebUIHydrationPlugin`. This follows an Islands Architecture approach: the server delivers fully-rendered HTML, authored Web Components hydrate on startup, and compiler-owned scriptless hosts remain dormant until browser code actually writes state.

- SSR hydration uses one DOM walk to discover `<!--wr-->`, `<!--wi-->`, and `<!--wc-->` comment markers, wire the relevant bindings using compiled metadata path indices, then remove SSR-only markers.
- Authored browser entries execute only after every SSR instance they may
upgrade has complete markup. Parser-inserted, non-async ES module scripts and
classic `defer` scripts satisfy this automatically; blocking classic scripts
must appear after all such instances. Under this loading contract,
`TemplateElement.connectedCallback()` hydrates synchronously, so
`super.connectedCallback()` returns only after that component's bindings,
events, and references are wired.
- Before a containing WebUI component hydrates, descendants must not
structurally mutate its SSR subtree. Hydration resolves compiled node paths
against the trusted server DOM and does not recover from pre-hydration node
insertion, removal, or reordering.
- Client-created DOM never reparses template syntax; it clones marker-free `h`,
upgrades the detached custom-element subtree, resolves `tx`, `ag`, the slots
embedded in `c` / `r`, and event target paths directly, then applies the first binding pass before
Expand Down
9 changes: 8 additions & 1 deletion docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,14 @@ The handler resolves `tokens.light` from the state, outputting:
`super.connectedCallback()` cannot reach the DOM — the write is dropped and
the runtime logs a `[WebUI] Hydration mismatch` warning. If the value must
appear in the first render, put it in the SSR state JSON; otherwise assign it
after `super.connectedCallback()`. The warning is development-only — it is
after `super.connectedCallback()`. The call is a synchronous hydration
boundary: when it returns, that component's bindings, events, and `w-ref`
references are wired. Load definitions through a parser-inserted, non-async
ES module script or a classic `defer` script. A blocking classic script must
follow every SSR instance it may upgrade. Descendants must not structurally
mutate a containing WebUI component's SSR subtree before it hydrates, because
node insertion, removal, or reordering shifts compiled paths. The warning is
development-only — it is
stripped from production bundles via the `__WEBUI_DEV__` compile-time flag
(`webui-press build` sets `__WEBUI_DEV__=false` automatically; self-bundled
apps add the define for production).
Expand Down
11 changes: 11 additions & 0 deletions docs/guide/concepts/hydration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ not need to enter browser state just because the component has an event handler,
An authored component with no decorators can therefore wire its behavior
without adding any startup state.

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.

Until a containing WebUI component hydrates, descendants must not insert,
remove, or reorder nodes in its SSR subtree. Hydration resolves compiled paths
against the trusted server DOM and cannot recover after those paths shift.

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.
Expand Down
11 changes: 11 additions & 0 deletions docs/guide/concepts/interactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,17 @@ Follow one rule to stay correct:
- **A value that must appear in the first render belongs in the SSR state.** Provide it in the JSON state so the server renders it; the client then hydrates against a matching DOM.
- **Assign anything else after `super.connectedCallback()`**, where `@observable` writes flow through live bindings.

WebUI treats `super.connectedCallback()` as a synchronous hydration boundary.
When it returns, that component's bindings, events, and `w-ref` references are
wired. This relies on the WebUI loading contract: load authored component code
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.

Descendants must not structurally mutate a containing WebUI component's SSR
subtree before that component hydrates. Inserting, removing, or reordering nodes
can shift compiled paths before WebUI wires them.

```ts
export class MyCounter extends WebUIElement {
@observable count = 0;
Expand Down
7 changes: 7 additions & 0 deletions packages/webui-framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ development-only and is dead-code-eliminated from production bundles via the
`false`). See the
[Interactivity Guide](https://microsoft.github.io/webui/guide/concepts/interactivity#setting-observable-state-during-setup).

`super.connectedCallback()` is the synchronous hydration boundary for an
authored component. When it returns, bindings, events, and `w-ref` references
are wired. Use a parser-inserted, non-async ES module script or a classic
`defer` script. A blocking classic script must follow every SSR instance it may
upgrade. Descendants must not structurally mutate a containing component's SSR
subtree before it hydrates, because hydration relies on stable compiled paths.

### DOM strategy (`--dom`)

The `--dom` flag controls how the server renders component content:
Expand Down
34 changes: 4 additions & 30 deletions packages/webui-framework/src/template-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,32 +151,11 @@ const EMPTY_SET: Set<string> = Object.freeze(new Set<string>()) as Set<string>;
const WEBUI_SET_STATE_KEY = Symbol.for('microsoft.webui.setStateKey');

const templateMetaByCtor = new WeakMap<Function, TemplateMeta>();
let domReadyQueue: Array<() => void> | null = null;

type TemplateObservedConstructor = CustomElementConstructor & {
readonly observedAttributes?: readonly string[];
};

function runWhenDomReady(callback: () => void): void {
if (document.readyState !== 'loading') {
callback();
return;
}
if (domReadyQueue) {
domReadyQueue.push(callback);
return;
}
domReadyQueue = [callback];
document.addEventListener('DOMContentLoaded', flushDomReadyQueue, { once: true });
}

function flushDomReadyQueue(): void {
const queue = domReadyQueue;
domReadyQueue = null;
if (!queue) return;
for (let i = 0; i < queue.length; i++) queue[i]();
}

// ── Helper: snapshot child nodes into a pre-allocated array ──────

function childNodesArray(parent: Node): Node[] {
Expand Down Expand Up @@ -357,15 +336,10 @@ export class TemplateElement extends HTMLElement {
return;
}
this.$meta = meta;
// Custom element upgrade timing: when the HTML parser encounters the
// opening tag, connectedCallback fires BEFORE children are parsed.
// If the document is still loading, defer to let the parser finish.
if (document.readyState === 'loading') {
runWhenDomReady(() => this.$mount(meta, false));
} else {
// Document is already parsed — children are available
this.$mount(meta, false);
}
// Under WebUI's loading contract, deferred scripts run after parsing and
// blocking scripts follow every component instance they may upgrade.
// Mount synchronously so super.connectedCallback() is the hydration boundary.
this.$mount(meta, false);
}

/** Mount the component after children are available. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,21 @@ export class MismatchBeforeSuper extends WebUIElement {
}
MismatchBeforeSuper.define('mismatch-before-super');

/** Assigns after `super.connectedCallback()`. No warning; the DOM updates. */
/**
* Assigns after `super.connectedCallback()`. No warning; the DOM updates.
* Also covers synchronous hydration during parsing for issue #393.
*/
export class MismatchAfterSuper extends WebUIElement {
@observable show = false;
@observable value = '';
box!: HTMLDivElement;
readyStateAtConnect = '';
referencesReadyAfterSuper = false;

connectedCallback(): void {
this.readyStateAtConnect = document.readyState;
super.connectedCallback();
this.referencesReadyAfterSuper = this.box instanceof HTMLDivElement;
this.show = true;
this.value = '3';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ test.describe('hydration-mismatch (#379)', () => {
}
});

test('super.connectedCallback hydrates synchronously while the document is loading', async ({ page }) => {
const lifecycle = await page.evaluate(() => {
const el = document.querySelector('mismatch-after-super') as {
readyStateAtConnect?: string;
referencesReadyAfterSuper?: boolean;
} | null;
return {
readyStateAtConnect: el?.readyStateAtConnect,
referencesReadyAfterSuper: el?.referencesReadyAfterSuper,
};
});

expect(lifecycle).toEqual({
readyStateAtConnect: 'loading',
referencesReadyAfterSuper: true,
});
});

test('seeded pre-ready values match the server render and do not warn', async ({ page }) => {
await expect(page.locator('mismatch-seeded .content')).toHaveText('CONTENT');
expect(await page.locator('mismatch-seeded .box').getAttribute('data-value')).toBe('3');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<div class="box" data-value="{{value}}">
<div class="box" data-value="{{value}}" w-ref="{box}">
<if condition="show"><span class="content">CONTENT</span></if>
</div>
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
{
"script": "module"
}
{}
Loading