From 3306b5c7eb6615ae475227b531ba894d9eec9979 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 23 Jul 2026 13:32:28 -0700 Subject: [PATCH 1/5] fix: make component hydration synchronous Remove the document-ready deferral so the base connected callback returns only after component bindings, events, and references are wired. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 6 ++++ docs/ai.md | 6 +++- docs/guide/concepts/hydration.md | 6 ++++ docs/guide/concepts/interactivity.md | 7 ++++ packages/webui-framework/README.md | 5 +++ .../webui-framework/src/template-element.ts | 33 ++----------------- .../fixtures/hydration-mismatch/element.ts | 10 +++++- .../hydration-mismatch.spec.ts | 18 ++++++++++ .../mismatch-after-super.html | 2 +- .../hydration-mismatch/webui.config.json | 4 +-- 10 files changed, 61 insertions(+), 36 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 15702b43..1f31202a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2757,6 +2757,12 @@ 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 their component markup has been + parsed. Non-async ES module scripts satisfy this automatically; classic + scripts must appear after the component markup they define. Under this + loading contract, `TemplateElement.connectedCallback()` hydrates synchronously, so + `super.connectedCallback()` returns only after that component's bindings, + events, and references are wired. - 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..cd588d11 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -1067,7 +1067,11 @@ 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 component definitions through a non-async ES + module script, or place a classic script after the component markup it + defines. 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..fbcbec25 100644 --- a/docs/guide/concepts/hydration.md +++ b/docs/guide/concepts/hydration.md @@ -46,6 +46,12 @@ 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 non-async ES module script, or place +a classic script after the component markup it defines. This guarantees that +the component subtree exists before upgrade. WebUI then hydrates synchronously +inside `super.connectedCallback()`; when it returns, bindings, events, and +`w-ref` references are ready. + 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..ac8ed5e0 100644 --- a/docs/guide/concepts/interactivity.md +++ b/docs/guide/concepts/interactivity.md @@ -559,6 +559,13 @@ 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 non-async ES module script, or place a classic script after the component +markup it defines. Do not run a parser-blocking definition script before the +component's closing tag. + ```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..05cbfb20 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -151,6 +151,11 @@ 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. Load component definitions with a non-async ES module script, or +place a classic script after the component markup it defines. + ### 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..790410af 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,9 @@ 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); - } + // WebUI browser entries execute after their component markup is parsed. + // 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" -} +{} From 79c874b1d0f8524154bb1d92a09c6ef420110483 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 23 Jul 2026 14:01:55 -0700 Subject: [PATCH 2/5] Clarify synchronous hydration loading contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/webui-framework/src/template-element.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index 790410af..a5b05b60 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -336,7 +336,8 @@ export class TemplateElement extends HTMLElement { return; } this.$meta = meta; - // WebUI browser entries execute after their component markup is parsed. + // Under WebUI's loading contract, browser entries execute after their + // component markup is parsed. // Mount synchronously so super.connectedCallback() is the hydration boundary. this.$mount(meta, false); } From f1a42ff755accc91a305a3f7331658024dd3a588 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 23 Jul 2026 14:43:16 -0700 Subject: [PATCH 3/5] docs: clarify hydration ordering constraints Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 13 +++++++++---- docs/ai.md | 9 ++++++--- docs/guide/concepts/hydration.md | 15 ++++++++++----- docs/guide/concepts/interactivity.md | 10 +++++++--- packages/webui-framework/README.md | 6 ++++-- packages/webui-framework/src/template-element.ts | 4 ++-- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 1f31202a..bbf53463 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2757,12 +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 their component markup has been - parsed. Non-async ES module scripts satisfy this automatically; classic - scripts must appear after the component markup they define. Under this - loading contract, `TemplateElement.connectedCallback()` hydrates synchronously, so +- 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 cd588d11..a111fed8 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -1069,9 +1069,12 @@ The handler resolves `tokens.light` from the state, outputting: appear in the first render, put it in the SSR state JSON; otherwise assign it 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 component definitions through a non-async ES - module script, or place a classic script after the component markup it - defines. The warning is development-only — it is + 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 fbcbec25..85f04fb9 100644 --- a/docs/guide/concepts/hydration.md +++ b/docs/guide/concepts/hydration.md @@ -46,11 +46,16 @@ 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 non-async ES module script, or place -a classic script after the component markup it defines. This guarantees that -the component subtree exists before upgrade. WebUI then hydrates synchronously -inside `super.connectedCallback()`; when it returns, bindings, events, and -`w-ref` references are ready. +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 diff --git a/docs/guide/concepts/interactivity.md b/docs/guide/concepts/interactivity.md index ac8ed5e0..ee19ed3d 100644 --- a/docs/guide/concepts/interactivity.md +++ b/docs/guide/concepts/interactivity.md @@ -562,9 +562,13 @@ Follow one rule to stay correct: 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 non-async ES module script, or place a classic script after the component -markup it defines. Do not run a parser-blocking definition script before the -component's closing tag. +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 { diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 05cbfb20..0408563b 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -153,8 +153,10 @@ development-only and is dead-code-eliminated from production bundles via the `super.connectedCallback()` is the synchronous hydration boundary for an authored component. When it returns, bindings, events, and `w-ref` references -are wired. Load component definitions with a non-async ES module script, or -place a classic script after the component markup it defines. +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`) diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index a5b05b60..692b8a43 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -336,8 +336,8 @@ export class TemplateElement extends HTMLElement { return; } this.$meta = meta; - // Under WebUI's loading contract, browser entries execute after their - // component markup is parsed. + // 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); } From cc1b21e8ecc66510294140b4fed6c12168522998 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 23 Jul 2026 14:56:38 -0700 Subject: [PATCH 4/5] fix: defer aggregate hydration completion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 6 ++ docs/ai.md | 2 +- docs/guide/concepts/performance.md | 7 +- packages/webui-framework/README.md | 6 +- packages/webui-framework/src/lifecycle.ts | 67 ++++++++++++++----- .../hydration-mismatch.spec.ts | 25 +++++++ 6 files changed, 91 insertions(+), 22 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index bbf53463..60681269 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2768,6 +2768,12 @@ WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled m 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. +- The one-shot `webui:hydration-complete` window event represents initial + document hydration under this loading contract. It is queued only after + `DOMContentLoaded` and after the active hydration count reaches zero, so + sequential custom-element upgrades cannot publish completion after only the + first component. Registrations made after this initial boundary are not part + of the event. - 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 a111fed8..6e2e5088 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -549,7 +549,7 @@ import { WebUIElement } from '@microsoft/webui-framework'; import './app-shell/app-shell.js'; import './user-card/user-card.js'; -// Optional: listen for hydration completion +// Fires once after initial parsing and all initially registered hydrations. window.addEventListener('webui:hydration-complete', () => { const total = performance.getEntriesByName('webui:hydrate:total', 'measure')[0]; console.log(`Hydration: ${total?.duration.toFixed(1)}ms`); diff --git a/docs/guide/concepts/performance.md b/docs/guide/concepts/performance.md index 834b1d83..5383ff09 100644 --- a/docs/guide/concepts/performance.md +++ b/docs/guide/concepts/performance.md @@ -269,9 +269,10 @@ supports the same named before/after baseline workflow through `cargo xtask`. ## Measuring Hydration Performance -WebUI emits a `webui:hydration-complete` event after all components have been -hydrated on the client. Use the Performance API to inspect per-component -timing: +WebUI emits the one-shot `webui:hydration-complete` event after the initial +document is parsed and all components registered under the loading contract +have hydrated. Components registered after this initial boundary are not +included. Use the Performance API to inspect per-component timing: ```typescript window.addEventListener('webui:hydration-complete', () => { diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 0408563b..27b0d8d3 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -811,7 +811,8 @@ The runtime exposes hydration timing via the Performance API: - Per component: `webui:hydrate::start` / `webui:hydrate::end` - Global: `webui:hydrate:total:start` / `webui:hydrate:total:end` -- Window event: `webui:hydration-complete` +- Window event: `webui:hydration-complete`, dispatched once after initial + parsing and all components registered under the loading contract hydrate ```ts window.addEventListener('webui:hydration-complete', () => { @@ -819,6 +820,9 @@ window.addEventListener('webui:hydration-complete', () => { }); ``` +Components registered after this initial boundary are not included in the +event. + --- ## Where to Look Next diff --git a/packages/webui-framework/src/lifecycle.ts b/packages/webui-framework/src/lifecycle.ts index 2d1e2b4c..303eeeb2 100644 --- a/packages/webui-framework/src/lifecycle.ts +++ b/packages/webui-framework/src/lifecycle.ts @@ -5,8 +5,8 @@ * Hydration lifecycle tracker. * * Tracks aggregate hydration timing via the Performance API and fires a - * global `webui:hydration-complete` event on `window` once every registered - * component has finished hydrating. + * global `webui:hydration-complete` event on `window` once the initial + * document is parsed and every registered component has finished hydrating. * * ## Performance marks * @@ -17,11 +17,15 @@ * * ## Window event * - * `webui:hydration-complete` — dispatched once on `window` when all - * components are hydrated. + * `webui:hydration-complete` — dispatched once on `window` when the initial + * document is parsed and no component is hydrating. */ -/** How many components are still waiting to hydrate. */ +const TOTAL_START_MARK = 'webui:hydrate:total:start'; +const TOTAL_END_MARK = 'webui:hydrate:total:end'; +const TOTAL_MEASURE = 'webui:hydrate:total'; + +/** How many components are actively hydrating. */ let pendingCount = 0; /** Whether the global start mark has been placed. */ @@ -30,13 +34,48 @@ let started = false; /** Whether the global complete event has already fired. */ let completed = false; +/** Whether initial document parsing and deferred/module scripts are complete. */ +let documentReady = typeof document !== 'undefined' && document.readyState === 'complete'; + +/** Whether a completion check is already queued. */ +let completionScheduled = false; + +/** End time of the latest hydration that returned the active count to zero. */ +let lastHydrationEndTime = 0; + +function scheduleCompletion(): void { + if (!documentReady || !started || pendingCount !== 0 || completed || completionScheduled) return; + + completionScheduled = true; + queueMicrotask(() => { + completionScheduled = false; + if (!documentReady || pendingCount !== 0 || completed) return; + + completed = true; + performance.mark(TOTAL_END_MARK, { startTime: lastHydrationEndTime }); + performance.measure(TOTAL_MEASURE, TOTAL_START_MARK, TOTAL_END_MARK); + window.dispatchEvent(new Event('webui:hydration-complete')); + }); +} + +function markDocumentReady(): void { + if (documentReady) return; + documentReady = true; + scheduleCompletion(); +} + +if (typeof document !== 'undefined' && typeof window !== 'undefined' && !documentReady) { + document.addEventListener('DOMContentLoaded', markDocumentReady, { once: true }); + window.addEventListener('load', markDocumentReady, { once: true }); +} + /** * Call before a component begins hydration. * Increments the pending counter and (once) places the global start mark. */ export function hydrationStart(): void { if (!started) { - performance.mark('webui:hydrate:total:start'); + performance.mark(TOTAL_START_MARK); started = true; } pendingCount++; @@ -44,19 +83,13 @@ export function hydrationStart(): void { /** * Call after a component has finished hydration. - * When the last component finishes, fires the global event + measure. + * Records the latest possible aggregate end and completes once parsing is done. */ export function hydrationEnd(): void { pendingCount--; - if (pendingCount <= 0 && !completed) { - completed = true; - performance.mark('webui:hydrate:total:end'); - performance.measure( - 'webui:hydrate:total', - 'webui:hydrate:total:start', - 'webui:hydrate:total:end', - ); - window.dispatchEvent(new Event('webui:hydration-complete')); - } + if (pendingCount !== 0 || completed) return; + + lastHydrationEndTime = performance.now(); + scheduleCompletion(); } 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 0664452d..0d2b57b3 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 @@ -34,6 +34,19 @@ test.describe('hydration-mismatch (#379)', () => { if (msg.type() === 'warning') warnings.push(msg.text()); }); + await page.addInitScript((tags) => { + const snapshots: Array<{ hydratedTags: string[]; readyState: DocumentReadyState }> = []; + (window as unknown as { hydrationSnapshots: typeof snapshots }).hydrationSnapshots = snapshots; + window.addEventListener('webui:hydration-complete', () => { + snapshots.push({ + hydratedTags: tags.filter((tag) => { + return (document.querySelector(tag) as { $ready?: boolean } | null)?.$ready === true; + }), + readyState: document.readyState, + }); + }); + }, ALL_TAGS as unknown as string[]); + await page.goto('/hydration-mismatch/fixture.html'); await page.waitForFunction((tags) => { @@ -110,6 +123,18 @@ test.describe('hydration-mismatch (#379)', () => { }); }); + test('global completion waits for parsing and every initial hydration', async ({ page }) => { + const snapshots = await page.evaluate(() => { + return (window as unknown as { + hydrationSnapshots?: Array<{ hydratedTags: string[]; readyState: DocumentReadyState }>; + }).hydrationSnapshots; + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots?.[0]?.hydratedTags).toEqual([...ALL_TAGS]); + expect(snapshots?.[0]?.readyState).not.toBe('loading'); + }); + 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'); From ccd57a44df99f9d4767916194ba9b9126ffd25ed Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Thu, 23 Jul 2026 15:15:36 -0700 Subject: [PATCH 5/5] revert: defer aggregate hydration completion Keep global completion semantics out of this PR because the runtime cannot infer dynamic import boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 6 -- docs/ai.md | 2 +- docs/guide/concepts/performance.md | 7 +- packages/webui-framework/README.md | 6 +- packages/webui-framework/src/lifecycle.ts | 67 +++++-------------- .../hydration-mismatch.spec.ts | 25 ------- 6 files changed, 22 insertions(+), 91 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 60681269..bbf53463 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2768,12 +2768,6 @@ WebUI Framework hydration assumes the SSR DOM, hydration markers, and compiled m 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. -- The one-shot `webui:hydration-complete` window event represents initial - document hydration under this loading contract. It is queued only after - `DOMContentLoaded` and after the active hydration count reaches zero, so - sequential custom-element upgrades cannot publish completion after only the - first component. Registrations made after this initial boundary are not part - of the event. - 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 6e2e5088..a111fed8 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -549,7 +549,7 @@ import { WebUIElement } from '@microsoft/webui-framework'; import './app-shell/app-shell.js'; import './user-card/user-card.js'; -// Fires once after initial parsing and all initially registered hydrations. +// Optional: listen for hydration completion window.addEventListener('webui:hydration-complete', () => { const total = performance.getEntriesByName('webui:hydrate:total', 'measure')[0]; console.log(`Hydration: ${total?.duration.toFixed(1)}ms`); diff --git a/docs/guide/concepts/performance.md b/docs/guide/concepts/performance.md index 5383ff09..834b1d83 100644 --- a/docs/guide/concepts/performance.md +++ b/docs/guide/concepts/performance.md @@ -269,10 +269,9 @@ supports the same named before/after baseline workflow through `cargo xtask`. ## Measuring Hydration Performance -WebUI emits the one-shot `webui:hydration-complete` event after the initial -document is parsed and all components registered under the loading contract -have hydrated. Components registered after this initial boundary are not -included. Use the Performance API to inspect per-component timing: +WebUI emits a `webui:hydration-complete` event after all components have been +hydrated on the client. Use the Performance API to inspect per-component +timing: ```typescript window.addEventListener('webui:hydration-complete', () => { diff --git a/packages/webui-framework/README.md b/packages/webui-framework/README.md index 27b0d8d3..0408563b 100644 --- a/packages/webui-framework/README.md +++ b/packages/webui-framework/README.md @@ -811,8 +811,7 @@ The runtime exposes hydration timing via the Performance API: - Per component: `webui:hydrate::start` / `webui:hydrate::end` - Global: `webui:hydrate:total:start` / `webui:hydrate:total:end` -- Window event: `webui:hydration-complete`, dispatched once after initial - parsing and all components registered under the loading contract hydrate +- Window event: `webui:hydration-complete` ```ts window.addEventListener('webui:hydration-complete', () => { @@ -820,9 +819,6 @@ window.addEventListener('webui:hydration-complete', () => { }); ``` -Components registered after this initial boundary are not included in the -event. - --- ## Where to Look Next diff --git a/packages/webui-framework/src/lifecycle.ts b/packages/webui-framework/src/lifecycle.ts index 303eeeb2..2d1e2b4c 100644 --- a/packages/webui-framework/src/lifecycle.ts +++ b/packages/webui-framework/src/lifecycle.ts @@ -5,8 +5,8 @@ * Hydration lifecycle tracker. * * Tracks aggregate hydration timing via the Performance API and fires a - * global `webui:hydration-complete` event on `window` once the initial - * document is parsed and every registered component has finished hydrating. + * global `webui:hydration-complete` event on `window` once every registered + * component has finished hydrating. * * ## Performance marks * @@ -17,15 +17,11 @@ * * ## Window event * - * `webui:hydration-complete` — dispatched once on `window` when the initial - * document is parsed and no component is hydrating. + * `webui:hydration-complete` — dispatched once on `window` when all + * components are hydrated. */ -const TOTAL_START_MARK = 'webui:hydrate:total:start'; -const TOTAL_END_MARK = 'webui:hydrate:total:end'; -const TOTAL_MEASURE = 'webui:hydrate:total'; - -/** How many components are actively hydrating. */ +/** How many components are still waiting to hydrate. */ let pendingCount = 0; /** Whether the global start mark has been placed. */ @@ -34,48 +30,13 @@ let started = false; /** Whether the global complete event has already fired. */ let completed = false; -/** Whether initial document parsing and deferred/module scripts are complete. */ -let documentReady = typeof document !== 'undefined' && document.readyState === 'complete'; - -/** Whether a completion check is already queued. */ -let completionScheduled = false; - -/** End time of the latest hydration that returned the active count to zero. */ -let lastHydrationEndTime = 0; - -function scheduleCompletion(): void { - if (!documentReady || !started || pendingCount !== 0 || completed || completionScheduled) return; - - completionScheduled = true; - queueMicrotask(() => { - completionScheduled = false; - if (!documentReady || pendingCount !== 0 || completed) return; - - completed = true; - performance.mark(TOTAL_END_MARK, { startTime: lastHydrationEndTime }); - performance.measure(TOTAL_MEASURE, TOTAL_START_MARK, TOTAL_END_MARK); - window.dispatchEvent(new Event('webui:hydration-complete')); - }); -} - -function markDocumentReady(): void { - if (documentReady) return; - documentReady = true; - scheduleCompletion(); -} - -if (typeof document !== 'undefined' && typeof window !== 'undefined' && !documentReady) { - document.addEventListener('DOMContentLoaded', markDocumentReady, { once: true }); - window.addEventListener('load', markDocumentReady, { once: true }); -} - /** * Call before a component begins hydration. * Increments the pending counter and (once) places the global start mark. */ export function hydrationStart(): void { if (!started) { - performance.mark(TOTAL_START_MARK); + performance.mark('webui:hydrate:total:start'); started = true; } pendingCount++; @@ -83,13 +44,19 @@ export function hydrationStart(): void { /** * Call after a component has finished hydration. - * Records the latest possible aggregate end and completes once parsing is done. + * When the last component finishes, fires the global event + measure. */ export function hydrationEnd(): void { pendingCount--; - if (pendingCount !== 0 || completed) return; - - lastHydrationEndTime = performance.now(); - scheduleCompletion(); + if (pendingCount <= 0 && !completed) { + completed = true; + performance.mark('webui:hydrate:total:end'); + performance.measure( + 'webui:hydrate:total', + 'webui:hydrate:total:start', + 'webui:hydrate:total:end', + ); + window.dispatchEvent(new Event('webui:hydration-complete')); + } } 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 0d2b57b3..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 @@ -34,19 +34,6 @@ test.describe('hydration-mismatch (#379)', () => { if (msg.type() === 'warning') warnings.push(msg.text()); }); - await page.addInitScript((tags) => { - const snapshots: Array<{ hydratedTags: string[]; readyState: DocumentReadyState }> = []; - (window as unknown as { hydrationSnapshots: typeof snapshots }).hydrationSnapshots = snapshots; - window.addEventListener('webui:hydration-complete', () => { - snapshots.push({ - hydratedTags: tags.filter((tag) => { - return (document.querySelector(tag) as { $ready?: boolean } | null)?.$ready === true; - }), - readyState: document.readyState, - }); - }); - }, ALL_TAGS as unknown as string[]); - await page.goto('/hydration-mismatch/fixture.html'); await page.waitForFunction((tags) => { @@ -123,18 +110,6 @@ test.describe('hydration-mismatch (#379)', () => { }); }); - test('global completion waits for parsing and every initial hydration', async ({ page }) => { - const snapshots = await page.evaluate(() => { - return (window as unknown as { - hydrationSnapshots?: Array<{ hydratedTags: string[]; readyState: DocumentReadyState }>; - }).hydrationSnapshots; - }); - - expect(snapshots).toHaveLength(1); - expect(snapshots?.[0]?.hydratedTags).toEqual([...ALL_TAGS]); - expect(snapshots?.[0]?.readyState).not.toBe('loading'); - }); - 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');