diff --git a/change/@microsoft-fast-element-21f5ec9e-5a25-44f7-8504-682ae8488103.json b/change/@microsoft-fast-element-21f5ec9e-5a25-44f7-8504-682ae8488103.json new file mode 100644 index 00000000000..c9b05feba1d --- /dev/null +++ b/change/@microsoft-fast-element-21f5ec9e-5a25-44f7-8504-682ae8488103.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Add hydration configuration for streamed Declarative Shadow DOM.", + "packageName": "@microsoft/fast-element", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/fast-element/DESIGN.md b/packages/fast-element/DESIGN.md index 752c5f110e5..e7bafbe2c79 100644 --- a/packages/fast-element/DESIGN.md +++ b/packages/fast-element/DESIGN.md @@ -94,7 +94,7 @@ The previous `FAST.getById()` slot registry, `FASTGlobal` type, and `KernelServi - Rendering is split into two modular paths. Hydration is pluggable: `enableHydration()` from `@microsoft/fast-element/hydration.js` installs a hook via `ElementController.installHydrationHook()`, keeping zero hydration imports in the core controller: - **Prerendered**: The hydration hook (installed by `enableHydration()`) registers the element in the static hydration tracker, fires the definition's `elementWillHydrate` callback, swaps `onAttributeChangedCallback` to a no-op so the upgrade-time burst of callbacks is discarded, hydrates the existing DOM via `template.hydrate()`, fires `elementDidHydrate`, then restores the standard handler and removes the element from the tracker. The entire method is wrapped in `try/finally` to guarantee cleanup even if an error occurs during hydration. After this point, all future attribute changes flow through the real handler with zero overhead. - **Client-side**: `renderClientSide()` clones the compiled fragment, binds, and appends to the host — the standard path with no prerender logic. -- **Static hydration tracking**: Hydration is opt-in via `enableHydration()` from `@microsoft/fast-element/hydration.js`, which creates a `HydrationTracker` and installs a pluggable hydration hook on `ElementController` via `ElementController.installHydrationHook()`. Until this is called, `renderTemplate()` always uses the client-side path — even if the element has a pre-existing shadow root. `HydrationTracker` manages a `Set` of pending elements, fires global callbacks (`hydrationStarted`, `hydrationComplete`), and fires `hydrationComplete` via a debounced `setTimeout(0)` after the last element finishes binding — ensuring all async template batches settle first. Per-element hydration callbacks (`elementWillHydrate`, `elementDidHydrate`) are stored on the `FASTElementDefinition.lifecycleCallbacks` and fired directly by the hydration hook. +- **Static hydration tracking**: Hydration is opt-in via `enableHydration()` from `@microsoft/fast-element/hydration.js`, which creates a `HydrationTracker` and installs a pluggable hydration hook on `ElementController` via `ElementController.installHydrationHook()`. Until this is called, `renderTemplate()` always uses the client-side path — even if the element has a pre-existing shadow root. `HydrationTracker` manages a `Set` of pending elements, fires global callbacks (`hydrationStarted`, `hydrationComplete`), and fires `hydrationComplete` via a debounced `setTimeout(0)` after the last element finishes binding — ensuring all async template batches settle first. By default, the hook no-ops for later prerendered batches after hydration completes; `enableHydration({ noopAfterHydrationComplete: false })` keeps the hook active for streamed Declarative Shadow DOM so new elements continue checking for an existing shadow root and hydrate instead of re-rendering it. Per-element hydration callbacks (`elementWillHydrate`, `elementDidHydrate`) are stored on the `FASTElementDefinition.lifecycleCallbacks` and fired directly by the hydration hook. - On `disconnect()`: calls `disconnectedCallback` on behaviors, unbinds the view. - `onAttributeChangedCallback()` is the standard handler that processes attribute changes. During the prerendered bind, it is temporarily swapped to a no-op (see above) to avoid redundant processing of server-rendered attribute values. - Exposes `addBehavior` / `removeBehavior` for dynamic `HostBehavior` management (used by `ElementStyles`). diff --git a/packages/fast-element/README.md b/packages/fast-element/README.md index 6d4a720f3ee..ccc9b1d2f84 100644 --- a/packages/fast-element/README.md +++ b/packages/fast-element/README.md @@ -188,6 +188,16 @@ enableHydration({ }); ``` +By default, hydration handles the initial prerendered batch and then no-ops +after `hydrationComplete` fires. If your app streams Declarative Shadow DOM +after the initial batch, keep the hydration hook active: + +```typescript +enableHydration({ + noopAfterHydrationComplete: false, +}); +``` + When hydration is enabled and a FAST element connects with an existing shadow root (from server-side rendering or declarative shadow DOM), `ElementController` detects this and hydrates instead of re-rendering. Two properties on the controller let you inspect the result: - **`isPrerendered: Promise`** — resolves `true` when the element had a declarative shadow root (DSD) at connect time, regardless of whether hydration ran. diff --git a/packages/fast-element/SIZES.md b/packages/fast-element/SIZES.md index e6ac7937aa9..50b8293e3ee 100644 --- a/packages/fast-element/SIZES.md +++ b/packages/fast-element/SIZES.md @@ -18,7 +18,7 @@ Bundle sizes for `@microsoft/fast-element` exports. | html (@microsoft/fast-element/html.js) | 25.92 KB | 8.50 KB | 7.61 KB | | repeat (@microsoft/fast-element/repeat.js) | 29.57 KB | 9.41 KB | 8.48 KB | | css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B | -| enableHydration (@microsoft/fast-element/hydration.js) | 43.27 KB | 13.19 KB | 11.88 KB | +| enableHydration (@microsoft/fast-element/hydration.js) | 43.62 KB | 13.27 KB | 11.95 KB | | declarativeTemplate (@microsoft/fast-element/declarative.js) | 58.77 KB | 18.45 KB | 16.46 KB | | ArrayObserver (@microsoft/fast-element/array-observer.js) | 12.51 KB | 4.45 KB | 4.01 KB | | observerMap (@microsoft/fast-element/observer-map.js) | 20.41 KB | 7.24 KB | 6.52 KB | diff --git a/packages/fast-element/docs/declarative-design.md b/packages/fast-element/docs/declarative-design.md index 021e6d30591..9d62c77b6d6 100644 --- a/packages/fast-element/docs/declarative-design.md +++ b/packages/fast-element/docs/declarative-design.md @@ -613,7 +613,7 @@ sequenceDiagram participant PerEl as TemplateLifecycleCallbacks participant Global as HydrationOptions - App->>Global: enableHydration(globalCallbacks) [optional] + App->>Global: enableHydration(options) [optional] App->>FER: await MyElement.define({name:'my-el', template: declarativeTemplate(callbacks)}, [attributeMap(), observerMap()]) note over FER: definition composed; resolver waits for template @@ -650,8 +650,13 @@ sequenceDiagram | `elementDidDefine(name)` | `declarativeTemplate(callbacks)` | After platform registration completes. | | `elementWillHydrate(source)` | `declarativeTemplate(callbacks)` | Before `ElementController` hydrates a prerendered instance; only after `enableHydration()`. | | `elementDidHydrate(source)` | `declarativeTemplate(callbacks)` | After an instance is fully hydrated; only after `enableHydration()`. | -| `hydrationStarted()` | `enableHydration(options)` | Once, when the first prerendered element begins hydrating. | -| `hydrationComplete()` | `enableHydration(options)` | Once, after all prerendered elements have completed hydration. | +| `hydrationStarted()` | `enableHydration(options)` | Once per active hydration batch, when the first prerendered element begins hydrating. | +| `hydrationComplete()` | `enableHydration(options)` | Once per active hydration batch, after all prerendered elements have completed hydration. | + +By default, hydration no-ops for later prerendered batches after +`hydrationComplete()` fires. Set +`enableHydration({ noopAfterHydrationComplete: false })` when Declarative Shadow +DOM may be streamed into the page after the initial hydration batch. For usage examples see [declarative-rendering-lifecycle.md](./declarative-rendering-lifecycle.md). diff --git a/packages/fast-element/docs/declarative-html.md b/packages/fast-element/docs/declarative-html.md index 254561718fe..8381c89645d 100644 --- a/packages/fast-element/docs/declarative-html.md +++ b/packages/fast-element/docs/declarative-html.md @@ -88,6 +88,16 @@ enableHydration({ }); ``` +The hydration hook no-ops for new prerendered elements after the first +`hydrationComplete` callback by default. Streaming scenarios that append +hydratable Declarative Shadow DOM later can keep the hook active: + +```typescript +enableHydration({ + noopAfterHydrationComplete: false, +}); +``` + Pass per-element lifecycle callbacks directly to `declarativeTemplate()`: ```typescript diff --git a/packages/fast-element/docs/declarative-rendering-lifecycle.md b/packages/fast-element/docs/declarative-rendering-lifecycle.md index 87d20b44564..1e6d33645e6 100644 --- a/packages/fast-element/docs/declarative-rendering-lifecycle.md +++ b/packages/fast-element/docs/declarative-rendering-lifecycle.md @@ -166,10 +166,13 @@ The lifecycle callbacks are split between two APIs: - `elementDidHydrate(source: HTMLElement)` - Called after an element completes hydration **Global hydration callbacks** — passed to `enableHydration()`: -- `hydrationStarted()` - Called once when the first prerendered element begins hydrating -- `hydrationComplete()` - Called once after all prerendered elements have completed hydration +- `hydrationStarted()` - Called when a prerendered hydration batch begins +- `hydrationComplete()` - Called after all prerendered elements in a hydration batch complete The `hydrationComplete` callback fires only after every prerendered element has finished binding. +By default, hydration no-ops for later prerendered batches after this callback. +Set `noopAfterHydrationComplete: false` in `enableHydration()` when streaming +Declarative Shadow DOM should continue hydrating after the initial batch. ### Callback Execution Order @@ -186,12 +189,12 @@ Template Processing Phase (asynchronous): 5. elementDidDefine(name) Hydration Phase (per element, only when enableHydration() has been called): - 6. hydrationStarted() [once, on first element] + 6. hydrationStarted() [once per active hydration batch] 7. elementWillHydrate(source) 8. [Hydration occurs] 9. elementDidHydrate(source) -Completion (called once for all elements): +Completion (called once per active hydration batch): 10. hydrationComplete() ``` @@ -208,6 +211,7 @@ import { enableHydration } from "@microsoft/fast-element/hydration.js"; // Global hydration events enableHydration({ + noopAfterHydrationComplete: false, hydrationStarted() { console.log("Hydration started"); }, diff --git a/packages/fast-element/docs/hydration/api-report.api.md b/packages/fast-element/docs/hydration/api-report.api.md index 890f9cc360d..023dbc70481 100644 --- a/packages/fast-element/docs/hydration/api-report.api.md +++ b/packages/fast-element/docs/hydration/api-report.api.md @@ -169,6 +169,7 @@ export class HydrationBindingError extends Error { export interface HydrationOptions { hydrationComplete?(): void; hydrationStarted?(): void; + noopAfterHydrationComplete?: boolean; } // @public (undocumented) @@ -184,6 +185,7 @@ export class HydrationTracker { add(element: HTMLElement): void; mergeOptions(incoming: HydrationOptions): void; remove(element: HTMLElement): void; + get shouldHydrate(): boolean; } // @beta diff --git a/packages/fast-element/src/components/enable-hydration.ts b/packages/fast-element/src/components/enable-hydration.ts index cec269b7f38..61e52296083 100644 --- a/packages/fast-element/src/components/enable-hydration.ts +++ b/packages/fast-element/src/components/enable-hydration.ts @@ -1,5 +1,5 @@ -import type { Mutable } from "../interfaces.js"; import { ensureHydrationRuntime } from "../hydration/runtime.js"; +import type { Mutable } from "../interfaces.js"; import { SourceLifetime } from "../observation/observable.js"; import type { ViewController } from "../templating/html-directive.js"; import type { HydratableElementViewTemplate } from "../templating/template.js"; @@ -28,19 +28,24 @@ let hookInstalled = false; * * Safe to call multiple times — the hydration hook is installed once * and subsequent calls merge their options into the shared tracker. + * By default, the hook stops hydrating new prerendered elements after + * the global `hydrationComplete` callback. Set + * `noopAfterHydrationComplete` to `false` for streaming scenarios that + * append hydratable Declarative Shadow DOM after the initial batch. * * @example * ```ts * import { enableHydration } from "@microsoft/fast-element/hydration.js"; * * enableHydration({ + * noopAfterHydrationComplete: false, * hydrationComplete() { * console.log("hydration complete"); * }, * }); * ``` * - * @param options - Optional callbacks for global hydration events. + * @param options - Optional global hydration callbacks and behavior. * @public */ export function enableHydration(options?: HydrationOptions): void { @@ -51,9 +56,8 @@ export function enableHydration(options?: HydrationOptions): void { hookInstalled = true; const activeTracker = tracker; - ElementController.installHydrationHook( - (controller, template, element, host) => { - if (!isHydratable(template)) { + ElementController.installHydrationHook((controller, template, element, host) => { + if (!activeTracker.shouldHydrate || !isHydratable(template)) { return false; } @@ -70,9 +74,11 @@ export function enableHydration(options?: HydrationOptions): void { const firstChild = host.firstChild!; const lastChild = host.lastChild!; - const view = ( - template as HydratableElementViewTemplate - ).hydrate(firstChild, lastChild, element); + const view = (template as HydratableElementViewTemplate).hydrate( + firstChild, + lastChild, + element, + ); (controller as any).view = view; @@ -103,8 +109,7 @@ export function enableHydration(options?: HydrationOptions): void { } return true; - }, - ); + }); } else if (options && tracker) { // Merge options into existing tracker for subsequent calls tracker.mergeOptions(options); diff --git a/packages/fast-element/src/components/hydration-tracker.ts b/packages/fast-element/src/components/hydration-tracker.ts index 18ecc446972..392fbafcaed 100644 --- a/packages/fast-element/src/components/hydration-tracker.ts +++ b/packages/fast-element/src/components/hydration-tracker.ts @@ -1,14 +1,26 @@ /** - * Options for configuring global hydration lifecycle events. + * Options for configuring global hydration lifecycle events and behavior. * @public */ export interface HydrationOptions { - /** Called once when the first prerendered element begins hydrating. */ + /** Called when a prerendered hydration batch begins. */ hydrationStarted?(): void; - /** Called after all prerendered elements have completed hydration. */ + /** Called after all prerendered elements in a hydration batch complete. */ hydrationComplete?(): void; + /** + * Indicates whether the hydration hook should stop handling new + * prerendered elements after hydration completes. + * + * @defaultValue true + */ + noopAfterHydrationComplete?: boolean; } +type HydrationCallbacks = Pick< + HydrationOptions, + "hydrationStarted" | "hydrationComplete" +>; + /** * Tracks prerendered elements through the hydration lifecycle and * fires global callbacks at start and completion. Per-element callbacks @@ -20,19 +32,35 @@ export interface HydrationOptions { export class HydrationTracker { private elements: Set = new Set(); private started = false; + private completed = false; private checkTimer: ReturnType | null = null; + private callbacks: HydrationCallbacks; + private noopAfterHydrationComplete: boolean; - constructor(private options: HydrationOptions) {} + constructor(options: HydrationOptions) { + this.callbacks = options; + this.noopAfterHydrationComplete = options.noopAfterHydrationComplete ?? true; + } + + /** + * Indicates whether the hydration hook should attempt to hydrate + * prerendered elements. + */ + public get shouldHydrate(): boolean { + return !this.completed || this.noopAfterHydrationComplete === false; + } /** * Registers an element as pending hydration. * Fires `hydrationStarted` on the first call. */ public add(element: HTMLElement): void { + this.completed = false; + if (!this.started) { this.started = true; try { - this.options.hydrationStarted?.(); + this.callbacks.hydrationStarted?.(); } catch { // A lifecycle callback must never prevent hydration. } @@ -60,11 +88,12 @@ export class HydrationTracker { if (this.elements.size === 0) { try { - this.options.hydrationComplete?.(); + this.callbacks.hydrationComplete?.(); } catch { // A lifecycle callback must never prevent post-hydration cleanup. } finally { this.started = false; + this.completed = true; } } }, 0); @@ -76,8 +105,8 @@ export class HydrationTracker { * callbacks so both the original and new callbacks fire. */ public mergeOptions(incoming: HydrationOptions): void { - const prev = this.options; - this.options = { + const prev = this.callbacks; + this.callbacks = { hydrationStarted: chainCallback( prev.hydrationStarted, incoming.hydrationStarted, @@ -87,6 +116,10 @@ export class HydrationTracker { incoming.hydrationComplete, ), }; + + if (incoming.noopAfterHydrationComplete !== void 0) { + this.noopAfterHydrationComplete = incoming.noopAfterHydrationComplete; + } } } diff --git a/packages/fast-element/src/components/hydration.pw.spec.ts b/packages/fast-element/src/components/hydration.pw.spec.ts index 3026a2324de..4782d3d7742 100644 --- a/packages/fast-element/src/components/hydration.pw.spec.ts +++ b/packages/fast-element/src/components/hydration.pw.spec.ts @@ -176,7 +176,7 @@ test.describe("The prerendered content optimization", () => { expect(result.shadowContent).toContain("CSR rendered"); }); - test("should fire global hydration callbacks for subsequent batches", async ({ + test("should skip hydrating subsequent batches by default after hydration completes", async ({ page, }) => { await page.goto("/"); @@ -214,6 +214,80 @@ test.describe("The prerendered content optimization", () => { ) ).define(); + async function appendPrerenderedElement(includeServerMarker = false) { + const container = document.createElement("div"); + document.body.appendChild(container); + (container as any).setHTMLUnsafe( + `<${name}>`, + ); + + const element = container.firstElementChild as any; + customElements.upgrade(container); + for (let i = 0; element.$fastController === void 0 && i < 10; i++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + } + const isHydrated = await element.$fastController.isHydrated; + const serverMarker = element.shadowRoot + ?.querySelector("span") + ?.getAttribute("data-server-marker"); + await new Promise(resolve => setTimeout(resolve, 0)); + + return { isHydrated, serverMarker }; + } + + const first = await appendPrerenderedElement(); + const second = await appendPrerenderedElement(true); + + return { events, first, second }; + }); + + expect(result.events).toEqual(["start", "complete"]); + expect(result.first.isHydrated).toBe(true); + expect(result.second.isHydrated).toBe(false); + expect(result.second.serverMarker).toBeNull(); + }); + + test("should keep hydration active for subsequent batches when configured", async ({ + page, + }) => { + await page.goto("/"); + + const result = await page.evaluate(async () => { + // @ts-expect-error: Client module. + const { + enableHydration, + FASTElement, + FASTElementDefinition, + html, + uniqueElementName, + } = await import("/main.js"); + + const events: string[] = []; + const name = uniqueElementName(); + + enableHydration({ + noopAfterHydrationComplete: false, + hydrationStarted() { + events.push("start"); + }, + hydrationComplete() { + events.push("complete"); + }, + }); + + ( + await FASTElementDefinition.compose( + class TestElement extends FASTElement { + static definition = { + name, + template: html`hydrated`, + }; + }, + ) + ).define(); + async function appendPrerenderedElement() { const container = document.createElement("div"); document.body.appendChild(container); @@ -222,16 +296,24 @@ test.describe("The prerendered content optimization", () => { ); const element = container.firstElementChild as any; - await element.$fastController.isHydrated; + customElements.upgrade(container); + for (let i = 0; element.$fastController === void 0 && i < 10; i++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + } + const isHydrated = await element.$fastController.isHydrated; await new Promise(resolve => setTimeout(resolve, 0)); + + return isHydrated; } - await appendPrerenderedElement(); - await appendPrerenderedElement(); + const first = await appendPrerenderedElement(); + const second = await appendPrerenderedElement(); - return events; + return { events, first, second }; }); - expect(result).toEqual(["start", "complete", "start", "complete"]); + expect(result.events).toEqual(["start", "complete", "start", "complete"]); + expect(result.first).toBe(true); + expect(result.second).toBe(true); }); }); diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/README.md b/packages/fast-element/test/declarative/fixtures/ecosystem/README.md index 5ab0e0c9ff1..734f34b2ef3 100644 --- a/packages/fast-element/test/declarative/fixtures/ecosystem/README.md +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/README.md @@ -7,5 +7,6 @@ performance monitoring. | Fixture | Description | |---|---| | `errors` | Error handling and edge cases in template rendering. | +| `hydration-config` | Hydration configuration for Declarative Shadow DOM appended after the initial hydration batch. | | `lifecycle-callbacks` | `declarativeTemplate()` and `enableHydration()` lifecycle callbacks such as `elementDidRegister`, `elementDidHydrate`, and `hydrationComplete`. | | `performance-metrics` | Performance monitoring and measurements during the component lifecycle. | diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/entry.html b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/entry.html new file mode 100644 index 00000000000..fb9969bab25 --- /dev/null +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/entry.html @@ -0,0 +1,11 @@ + + + + + Hydration Config Test + + + + + + diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/fast-build.config.json b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/fast-build.config.json new file mode 100644 index 00000000000..c291972cdad --- /dev/null +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/fast-build.config.json @@ -0,0 +1,6 @@ +{ + "entry": "entry.html", + "state": "state.json", + "output": "index.html", + "templates": "templates.html" +} diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/hydration-config.spec.ts b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/hydration-config.spec.ts new file mode 100644 index 00000000000..14d0bcfb099 --- /dev/null +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/hydration-config.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Hydration configuration", () => { + test("hydrates declarative shadow DOM appended after completion", async ({ + page, + }) => { + await page.goto("/fixtures/ecosystem/hydration-config/"); + await page.waitForFunction(() => (window as any).hydrationCompletionCount >= 1); + + const supportsSetHTMLUnsafe = await page.evaluate( + () => "setHTMLUnsafe" in Element.prototype, + ); + test.skip( + !supportsSetHTMLUnsafe, + "Declarative Shadow DOM setHTMLUnsafe() is not supported.", + ); + + const result = await page.evaluate(async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + (container as any).setHTMLUnsafe(` + + + + `); + + const element = container.firstElementChild as any; + customElements.upgrade(container); + for (let i = 0; element.$fastController === void 0 && i < 10; i++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + } + const isHydrated = await element.$fastController.isHydrated; + const label = element.shadowRoot!.querySelector("#label")!; + const serverNodeAttribute = label.getAttribute("data-server-node"); + + element.label = "Updated"; + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => setTimeout(resolve, 0)); + + return { + completionCount: (window as any).hydrationCompletionCount, + isHydrated, + serverNodeAttribute, + text: label.textContent, + }; + }); + + expect(result.isHydrated).toBe(true); + expect(result.serverNodeAttribute).toBe("late"); + expect(result.text).toBe("Updated"); + expect(result.completionCount).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/index.html b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/index.html new file mode 100644 index 00000000000..f1c3b37f8dc --- /dev/null +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/index.html @@ -0,0 +1,15 @@ + + + + + Hydration Config Test + + + + + + + + + + diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/main.ts b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/main.ts new file mode 100644 index 00000000000..1738c150bdf --- /dev/null +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/main.ts @@ -0,0 +1,26 @@ +import { attr } from "@microsoft/fast-element/attr.js"; +import { declarativeTemplate } from "@microsoft/fast-element/declarative.js"; +import { FASTElement } from "@microsoft/fast-element/fast-element.js"; +import { enableHydration } from "@microsoft/fast-element/hydration.js"; + +export const hydrationEvents: string[] = []; + +enableHydration({ + noopAfterHydrationComplete: false, + hydrationComplete(): void { + hydrationEvents.push("complete"); + (window as any).hydrationCompletionCount = hydrationEvents.length; + }, +}); + +export class HydrationOptionsElement extends FASTElement { + @attr + label: string = "Initial"; +} + +HydrationOptionsElement.define({ + name: "hydration-config-element", + template: declarativeTemplate(), +}); + +(window as any).hydrationEvents = hydrationEvents; diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/state.json b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/state.json new file mode 100644 index 00000000000..611e34e30be --- /dev/null +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/state.json @@ -0,0 +1,3 @@ +{ + "label": "Initial" +} diff --git a/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/templates.html b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/templates.html new file mode 100644 index 00000000000..3aa98c8dc11 --- /dev/null +++ b/packages/fast-element/test/declarative/fixtures/ecosystem/hydration-config/templates.html @@ -0,0 +1,3 @@ + + + diff --git a/sites/website/src/docs/3.x/declarative-templates/lifecycle-callbacks.md b/sites/website/src/docs/3.x/declarative-templates/lifecycle-callbacks.md index 220556099c4..c42194ad22b 100644 --- a/sites/website/src/docs/3.x/declarative-templates/lifecycle-callbacks.md +++ b/sites/website/src/docs/3.x/declarative-templates/lifecycle-callbacks.md @@ -35,6 +35,15 @@ enableHydration({ ``` Without calling `enableHydration()`, prerendered content is discarded and elements render client-side. +By default, hydration no-ops for new prerendered elements after the first +`hydrationComplete` callback. Set `noopAfterHydrationComplete: false` when your +app streams Declarative Shadow DOM after the initial batch: + +```typescript +enableHydration({ + noopAfterHydrationComplete: false, +}); +``` ## Per-Element Callbacks @@ -79,11 +88,11 @@ Callbacks fire in the following order for each element: 2. templateWillUpdate(name) — before template is parsed 3. templateDidUpdate(name) — after template is assigned 4. elementDidDefine(name) — after customElements.define() -5. hydrationStarted() — once, on first hydrating element +5. hydrationStarted() — once per active hydration batch 6. elementWillHydrate(source) — before element hydrates 7. [Hydration occurs] 8. elementDidHydrate(source) — after element hydrates -9. hydrationComplete() — once, after all elements finish +9. hydrationComplete() — once per active hydration batch ``` Steps 5–9 only fire when `enableHydration()` has been called and the element has prerendered content. diff --git a/sites/website/src/docs/3.x/declarative-templates/server-rendering.md b/sites/website/src/docs/3.x/declarative-templates/server-rendering.md index 77921eba4fc..5dc6cbc5893 100644 --- a/sites/website/src/docs/3.x/declarative-templates/server-rendering.md +++ b/sites/website/src/docs/3.x/declarative-templates/server-rendering.md @@ -74,7 +74,7 @@ The end-to-end flow from server to interactive page follows these steps: 2. **Browser loads HTML** — The browser parses the page. Declarative Shadow DOM `