diff --git a/DESIGN.md b/DESIGN.md index 15702b43..bbf53463 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 ``, ``, and `` 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 diff --git a/docs/ai.md b/docs/ai.md index 42dd30b8..a111fed8 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -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). diff --git a/docs/guide/concepts/hydration.md b/docs/guide/concepts/hydration.md index 5dd99cd1..85f04fb9 100644 --- a/docs/guide/concepts/hydration.md +++ b/docs/guide/concepts/hydration.md @@ -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. diff --git a/docs/guide/concepts/interactivity.md b/docs/guide/concepts/interactivity.md index 7b91e759..ee19ed3d 100644 --- a/docs/guide/concepts/interactivity.md +++ b/docs/guide/concepts/interactivity.md @@ -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; diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index b12f8684..0408563b 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -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: diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index 76903a48..692b8a43 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -151,32 +151,11 @@ const EMPTY_SET: Set = Object.freeze(new Set()) as Set; const WEBUI_SET_STATE_KEY = Symbol.for('microsoft.webui.setStateKey'); const templateMetaByCtor = new WeakMap(); -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[] { @@ -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. */ diff --git a/packages/webui-framework/tests/fixtures/hydration-mismatch/element.ts b/packages/webui-framework/tests/fixtures/hydration-mismatch/element.ts index 5a047c25..98fc3806 100644 --- a/packages/webui-framework/tests/fixtures/hydration-mismatch/element.ts +++ b/packages/webui-framework/tests/fixtures/hydration-mismatch/element.ts @@ -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'; } diff --git a/packages/webui-framework/tests/fixtures/hydration-mismatch/hydration-mismatch.spec.ts b/packages/webui-framework/tests/fixtures/hydration-mismatch/hydration-mismatch.spec.ts index 16a684c7..0664452d 100644 --- a/packages/webui-framework/tests/fixtures/hydration-mismatch/hydration-mismatch.spec.ts +++ b/packages/webui-framework/tests/fixtures/hydration-mismatch/hydration-mismatch.spec.ts @@ -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'); diff --git a/packages/webui-framework/tests/fixtures/hydration-mismatch/src/mismatch-after-super/mismatch-after-super.html b/packages/webui-framework/tests/fixtures/hydration-mismatch/src/mismatch-after-super/mismatch-after-super.html index b0b8ad6f..ca6b8d25 100644 --- a/packages/webui-framework/tests/fixtures/hydration-mismatch/src/mismatch-after-super/mismatch-after-super.html +++ b/packages/webui-framework/tests/fixtures/hydration-mismatch/src/mismatch-after-super/mismatch-after-super.html @@ -1,3 +1,3 @@ -
+
CONTENT
diff --git a/packages/webui-framework/tests/fixtures/hydration-mismatch/webui.config.json b/packages/webui-framework/tests/fixtures/hydration-mismatch/webui.config.json index 36c92002..0967ef42 100644 --- a/packages/webui-framework/tests/fixtures/hydration-mismatch/webui.config.json +++ b/packages/webui-framework/tests/fixtures/hydration-mismatch/webui.config.json @@ -1,3 +1 @@ -{ - "script": "module" -} +{}