From c8ecebca876da310227592b0121d97d591959182 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:45:22 -0700 Subject: [PATCH 1/6] fix: remove legacy hydration marker support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c66c1387-9a1b-4386-8d0f-a115ef5706db --- ...-18fad60c-d54c-48ef-be11-88bbbada8c2e.json | 7 + packages/fast-element/SIZES.md | 10 +- .../src/components/hydration.pw.spec.ts | 144 +++++------------- .../fast-element/src/components/hydration.ts | 116 +------------- .../src/hydration/target-builder.ts | 42 +---- 5 files changed, 53 insertions(+), 266 deletions(-) create mode 100644 change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json diff --git a/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json b/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json new file mode 100644 index 00000000000..826e26ca923 --- /dev/null +++ b/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Remove support for legacy indexed hydration markers.", + "packageName": "@microsoft/fast-element", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/fast-element/SIZES.md b/packages/fast-element/SIZES.md index 1b48086fe26..d90852fbfc0 100644 --- a/packages/fast-element/SIZES.md +++ b/packages/fast-element/SIZES.md @@ -4,7 +4,7 @@ Bundle sizes for `@microsoft/fast-element` exports. | Export | Minified | Gzip | Brotli | |--------|----------|------|--------| -| CDN Rollup Bundle | 78.79 KB | 23.43 KB | 20.84 KB | +| CDN Rollup Bundle | 76.80 KB | 22.94 KB | 20.31 KB | | FASTElement (@microsoft/fast-element/fast-element.js) | 23.11 KB | 7.12 KB | 6.41 KB | | Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 290 B | | Observable (@microsoft/fast-element/observable.js) | 6.75 KB | 2.51 KB | 2.23 KB | @@ -15,11 +15,11 @@ Bundle sizes for `@microsoft/fast-element` exports. | slotted (@microsoft/fast-element/slotted.js) | 4.66 KB | 1.81 KB | 1.59 KB | | volatile (@microsoft/fast-element/volatile.js) | 6.84 KB | 2.54 KB | 2.26 KB | | when (@microsoft/fast-element/when.js) | 1.88 KB | 731 B | 589 B | -| html (@microsoft/fast-element/html.js) | 27.91 KB | 8.98 KB | 8.04 KB | -| repeat (@microsoft/fast-element/repeat.js) | 31.80 KB | 10.00 KB | 9.03 KB | +| html (@microsoft/fast-element/html.js) | 25.94 KB | 8.43 KB | 7.57 KB | +| repeat (@microsoft/fast-element/repeat.js) | 29.84 KB | 9.45 KB | 8.52 KB | | css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B | -| enableHydration (@microsoft/fast-element/hydration.js) | 46.71 KB | 13.94 KB | 12.51 KB | -| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.33 KB | 19.49 KB | 17.46 KB | +| enableHydration (@microsoft/fast-element/hydration.js) | 44.74 KB | 13.39 KB | 12.05 KB | +| declarativeTemplate (@microsoft/fast-element/declarative.js) | 60.36 KB | 18.91 KB | 16.99 KB | | ArrayObserver (@microsoft/fast-element/arrays.js) | 12.55 KB | 4.46 KB | 4.03 KB | | observerMap (@microsoft/fast-element/observer-map.js) | 21.96 KB | 7.73 KB | 6.97 KB | | attributeMap (@microsoft/fast-element/attribute-map.js) | 15.31 KB | 5.41 KB | 4.88 KB | diff --git a/packages/fast-element/src/components/hydration.pw.spec.ts b/packages/fast-element/src/components/hydration.pw.spec.ts index 2de1ba19253..97dda986813 100644 --- a/packages/fast-element/src/components/hydration.pw.spec.ts +++ b/packages/fast-element/src/components/hydration.pw.spec.ts @@ -451,128 +451,52 @@ test.describe("The prerendered content optimization", () => { expect(result.spanCount).toBe(3); }); - test("should hydrate legacy indexed repeat markers", async ({ page }) => { + test("does not recognize legacy hydration markers", async ({ page }) => { await page.goto("/"); - const element = await page.evaluateHandle(async () => { + const result = await page.evaluate(async () => { const { - enableHydration, - FASTElement, - FASTElementDefinition, - html, - repeat, - uniqueElementName, + HydrationMarkup, // @ts-expect-error: Client module. } = await import("/main.js"); - enableHydration(); - const name = uniqueElementName(); - - class TestElement extends FASTElement { - items = ["one", "two"]; - - static definition = { - name, - template: html` - ${repeat((x: { items: any }) => x.items, html`${(x: any) => x}`)} - `, - }; - } - - await (await FASTElementDefinition.compose(TestElement)).define(); - - const container = document.createElement("div"); - document.body.appendChild(container); - (container as any).setHTMLUnsafe( - `<${name}>`, - ); - - return container.firstElementChild; - }); - - expect(await element.evaluate((x: any) => x.$fastController)).toEqual( - expect.anything(), - ); - - const result = await element.evaluate(async (element: any) => { - await element.$fastController.isHydrated; - await new Promise(resolve => requestAnimationFrame(resolve)); + const element = document.createElement("div"); + element.setAttribute("data-fe-b", "0 1"); + element.setAttribute("data-fe-b-2", ""); + element.setAttribute("data-fe-c-3-2", ""); return { - text: element.shadowRoot?.textContent?.replace(/\s+/g, "") ?? "", - spanCount: element.shadowRoot?.querySelectorAll("span").length ?? 0, + attributeCount: HydrationMarkup.parseAttributeBindingCount(element), + contentEnd: HydrationMarkup.isContentBindingEndMarker( + "fe-b$$end$$0$$scope$$fe-b", + ), + contentStart: HydrationMarkup.isContentBindingStartMarker( + "fe-b$$start$$0$$scope$$fe-b", + ), + elementEnd: HydrationMarkup.isElementBoundaryEndMarker( + document.createComment("fe-eb$$end$$element$$fe-eb"), + ), + elementStart: HydrationMarkup.isElementBoundaryStartMarker( + document.createComment("fe-eb$$start$$element$$fe-eb"), + ), + repeatEnd: HydrationMarkup.isRepeatViewEndMarker( + "fe-repeat$$end$$0$$fe-repeat", + ), + repeatStart: HydrationMarkup.isRepeatViewStartMarker( + "fe-repeat$$start$$0$$fe-repeat", + ), }; }); - expect(result.text).toBe("onetwo"); - expect(result.spanCount).toBe(2); - }); - - test("should hydrate legacy indexed attribute markers", async ({ page }) => { - await page.goto("/"); - - const element = await page.evaluateHandle(async () => { - const { - enableHydration, - FASTElement, - FASTElementDefinition, - html, - ref, - uniqueElementName, - // @ts-expect-error: Client module. - } = await import("/main.js"); - - enableHydration(); - const name = uniqueElementName(); - - class TestElement extends FASTElement { - count = 0; - disabled = false; - input!: HTMLInputElement; - - static definition = { - name, - template: html` - - - `, - }; - } - - await (await FASTElementDefinition.compose(TestElement)).define(); - - const container = document.createElement("div"); - document.body.appendChild(container); - (container as any).setHTMLUnsafe( - `<${name}>`, - ); - - return container.firstElementChild; + expect(result).toEqual({ + attributeCount: null, + contentEnd: false, + contentStart: false, + elementEnd: false, + elementStart: false, + repeatEnd: false, + repeatStart: false, }); - - expect(await element.evaluate((x: any) => x.$fastController)).toEqual( - expect.anything(), - ); - - const result = await element.evaluate(async (element: any) => { - await element.$fastController.isHydrated; - await new Promise(resolve => requestAnimationFrame(resolve)); - element.shadowRoot!.querySelector("button")!.click(); - - return { - count: element.count, - inputResolved: - element.input === element.shadowRoot!.querySelector("input"), - }; - }); - - expect(result.count).toBe(1); - expect(result.inputResolved).toBe(true); }); test("should remove extra repeat ranges when SSR rendered more items", async ({ diff --git a/packages/fast-element/src/components/hydration.ts b/packages/fast-element/src/components/hydration.ts index 4a76689409c..a2d4c9446e8 100644 --- a/packages/fast-element/src/components/hydration.ts +++ b/packages/fast-element/src/components/hydration.ts @@ -33,20 +33,8 @@ import type { * Attribute bindings use a single `data-fe` attribute whose value is * the count of attribute binding factories targeting the element: *
(3 attribute bindings) - * - * WebUI versions that predate the data-free marker format still emit indexed - * markers. The parser below accepts those legacy markers so existing WebUI SSR - * output can hydrate against the newer FAST runtime. */ -const legacyBindingStartMarker = /fe-b\$\$start\$\$(\d+)\$\$(.+)\$\$fe-b/; -const legacyBindingEndMarker = /fe-b\$\$end\$\$(\d+)\$\$(.+)\$\$fe-b/; -const legacyRepeatViewStartMarker = /fe-repeat\$\$start\$\$(\d+)\$\$fe-repeat/; -const legacyRepeatViewEndMarker = /fe-repeat\$\$end\$\$(\d+)\$\$fe-repeat/; -const legacyElementBoundaryStartMarker = /^(?:.{0,1000})fe-eb\$\$start\$\$(.+?)\$\$fe-eb/; -const legacyElementBoundaryEndMarker = - /fe-eb\$\$end\$\$(.{0,1000})\$\$fe-eb(?:.{0,1000})$/; - function isComment(node: Node): node is Comment { return node && node.nodeType === Node.COMMENT_NODE; } @@ -58,8 +46,6 @@ function isComment(node: Node): node is Comment { export const HydrationMarkup = Object.freeze({ // Single attribute marker format (count only) attributeMarkerName: "data-fe", - legacyAttributeMarkerName: "data-fe-b", - legacyCompactAttributeMarkerName: "data-fe-c", // Content binding markers (no arguments) contentBindingStartMarker(): string { @@ -87,28 +73,22 @@ export const HydrationMarkup = Object.freeze({ // Detection — simple string equality isContentBindingStartMarker(data: string): boolean { - return data === "fe:b" || legacyBindingStartMarker.test(data); + return data === "fe:b"; }, isContentBindingEndMarker(data: string): boolean { - return data === "fe:/b" || legacyBindingEndMarker.test(data); + return data === "fe:/b"; }, isRepeatViewStartMarker(data: string): boolean { - return data === "fe:r" || legacyRepeatViewStartMarker.test(data); + return data === "fe:r"; }, isRepeatViewEndMarker(data: string): boolean { - return data === "fe:/r" || legacyRepeatViewEndMarker.test(data); + return data === "fe:/r"; }, isElementBoundaryStartMarker(node: Node): boolean { - return ( - isComment(node) && - (node.data === "fe:e" || legacyElementBoundaryStartMarker.test(node.data)) - ); + return isComment(node) && node.data === "fe:e"; }, isElementBoundaryEndMarker(node: Node): boolean { - return ( - isComment(node) && - (node.data === "fe:/e" || legacyElementBoundaryEndMarker.test(node.data)) - ); + return isComment(node) && node.data === "fe:/e"; }, /** @@ -142,92 +122,8 @@ export const HydrationMarkup = Object.freeze({ return count; }, - - parseLegacyAttributeBindingIndices(node: Element): number[] | null { - const indices: number[] = []; - const attr = node.getAttribute(this.legacyAttributeMarkerName); - - if (attr !== null) { - for (const value of attr.trim().split(/\s+/)) { - if (value === "") { - continue; - } - - const index = Number(value); - - if (!Number.isInteger(index) || index < 0) { - throw FAST.error(Message.invalidHydrationAttributeMarker, { - value: attr, - }); - } - - indices.push(index); - } - } - - const enumeratedPrefix = `${this.legacyAttributeMarkerName}-`; - const compactPrefix = `${this.legacyCompactAttributeMarkerName}-`; - - for (const name of node.getAttributeNames()) { - if (name.startsWith(enumeratedPrefix)) { - const index = Number(name.slice(enumeratedPrefix.length)); - - if (!Number.isInteger(index) || index < 0) { - throw FAST.error(Message.invalidHydrationAttributeMarker, { - value: name, - }); - } - - indices.push(index); - } else if (name.startsWith(compactPrefix)) { - const [start, count] = name - .slice(compactPrefix.length) - .split("-") - .map(value => Number(value)); - - if ( - !Number.isInteger(start) || - !Number.isInteger(count) || - start < 0 || - count < 1 - ) { - throw FAST.error(Message.invalidHydrationAttributeMarker, { - value: name, - }); - } - - for (let i = 0; i < count; i++) { - indices.push(start + i); - } - } - } - - return indices.length === 0 ? null : indices; - }, - - removeLegacyAttributeBindingMarkers(node: Element): void { - node.removeAttribute(this.legacyAttributeMarkerName); - - for (const name of node.getAttributeNames()) { - if ( - name.startsWith(`${this.legacyAttributeMarkerName}-`) || - name.startsWith(`${this.legacyCompactAttributeMarkerName}-`) - ) { - node.removeAttribute(name); - } - } - }, - - parseLegacyContentBindingStartIndex(data: string): number | null { - return parseLegacyIntMarker(legacyBindingStartMarker, data); - }, }); -function parseLegacyIntMarker(pattern: RegExp, data: string): number | null { - const match = pattern.exec(data); - return match === null ? null : Number(match[1]); -} - /** * @internal */ diff --git a/packages/fast-element/src/hydration/target-builder.ts b/packages/fast-element/src/hydration/target-builder.ts index d08f23d6318..e2a281ae2f3 100644 --- a/packages/fast-element/src/hydration/target-builder.ts +++ b/packages/fast-element/src/hydration/target-builder.ts @@ -157,39 +157,6 @@ export function buildViewBindingTargets( switch (node.nodeType) { case Node.ELEMENT_NODE: { const element = node as Element; - const legacyIndices = - HydrationMarkup.parseLegacyAttributeBindingIndices(element); - - if (legacyIndices !== null) { - for (const index of legacyIndices) { - const factoryIndex = index + hydrationIndexOffset; - const factory = factories[factoryIndex]; - if (!factory) { - const expected = formatNoMoreAttributeBindings( - factories.length, - ); - const result = getHydrationDiagnostic().formatStructuralError( - node, - getHostName(node), - expected, - ); - throw new HydrationTargetElementError( - result.message, - factories, - element, - result.expected, - result.received, - ); - } - - targetFactory(factory, node, targets); - factoryPointer = Math.max(factoryPointer, factoryIndex + 1); - } - - HydrationMarkup.removeLegacyAttributeBindingMarkers(element); - break; - } - const count = HydrationMarkup.parseAttributeBindingCount(element); if (count !== null) { for (let i = 0; i < count; i++) { @@ -226,14 +193,7 @@ export function buildViewBindingTargets( skipToElementBoundaryEnd(walker, factories, node); } else if (HydrationMarkup.isContentBindingStartMarker(data)) { // Content binding — consume next factory - const legacyIndex = - HydrationMarkup.parseLegacyContentBindingStartIndex(data); - const factoryIndex = - legacyIndex === null - ? factoryPointer++ - : legacyIndex + hydrationIndexOffset; - const factory = factories[factoryIndex]; - factoryPointer = Math.max(factoryPointer, factoryIndex + 1); + const factory = factories[factoryPointer++]; if (!factory) { const expected = formatNoMoreContentBindings(factories.length); From 4bb29ff9e2392722c2038805ea0b9d12b1c73622 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:08:42 -0700 Subject: [PATCH 2/6] feat: add opt-in v2 hydration markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c66c1387-9a1b-4386-8d0f-a115ef5706db --- ...-18fad60c-d54c-48ef-be11-88bbbada8c2e.json | 4 +- packages/fast-element/DESIGN.md | 2 +- packages/fast-element/README.md | 10 + packages/fast-element/SIZES.md | 10 +- .../fast-element/docs/declarative/design.md | 16 +- .../fast-element/docs/declarative/syntax.md | 10 + .../src/components/enable-hydration.ts | 16 ++ .../src/components/hydration-tracker.ts | 10 + .../src/components/hydration.pw.spec.ts | 223 +++++++++++++++--- .../fast-element/src/components/hydration.ts | 113 +-------- .../fast-element/src/hydration/exports.ts | 2 + .../fast-element/src/hydration/markers.ts | 203 ++++++++++++++++ .../fast-element/src/hydration/markers_v2.ts | 175 ++++++++++++++ .../fast-element/src/hydration/messages.ts | 16 +- .../src/hydration/target-builder.ts | 59 +++-- .../fast-element/src/templating/repeat.ts | 13 +- packages/fast-element/test/main.ts | 1 + .../src/docs/3.x/migration-guide/hydration.md | 27 ++- .../src/docs/3.x/resources/export-sizes.md | 10 +- 19 files changed, 713 insertions(+), 207 deletions(-) create mode 100644 packages/fast-element/src/hydration/markers.ts create mode 100644 packages/fast-element/src/hydration/markers_v2.ts diff --git a/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json b/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json index 826e26ca923..5669f711ea5 100644 --- a/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json +++ b/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json @@ -1,6 +1,6 @@ { - "type": "patch", - "comment": "Remove support for legacy indexed hydration markers.", + "type": "minor", + "comment": "Add opt-in hydration support for FAST Element 2.x indexed markers.", "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 cc6a1653bdc..92776e2939b 100644 --- a/packages/fast-element/DESIGN.md +++ b/packages/fast-element/DESIGN.md @@ -174,7 +174,7 @@ formatter in a custom `HydrationDebugger` and pass it through `enableHydration({ debugger })`) if you want to route diagnostics into logging, telemetry, or a devtools panel. -- **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 and resolves the returned controller's `whenHydrated()` promise via a debounced `setTimeout(0)` after the last element finishes binding — ensuring all async template batches settle first. It also resolves tag-specific `whenHydrated(tagName)` promises when work for that FAST element tag completes. By default, the hook no-ops for later prerendered batches after hydration completes; `enableHydration({ stopHydration: StopHydration.never })` 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. In that mode, the global `whenHydrated()` promise intentionally remains pending. +- **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 and resolves the returned controller's `whenHydrated()` promise via a debounced `setTimeout(0)` after the last element finishes binding — ensuring all async template batches settle first. It also resolves tag-specific `whenHydrated(tagName)` promises when work for that FAST element tag completes. Hydration target discovery uses the 3.x data-free marker reader by default; `enableHydration({ markers: v2 })` installs the isolated 2.x indexed marker reader for staged SSR migrations. By default, the hook no-ops for later prerendered batches after hydration completes; `enableHydration({ stopHydration: StopHydration.never })` 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. In that mode, the global `whenHydrated()` promise intentionally remains pending. - 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 2ac45a7e9c9..56659e5cf11 100644 --- a/packages/fast-element/README.md +++ b/packages/fast-element/README.md @@ -212,6 +212,16 @@ enableHydration({ }); ``` +FAST Element 3.x data-free hydration markers are used by default. To hydrate +server output that still contains FAST Element 2.x indexed markers, opt into the +legacy marker reader: + +```typescript +import { enableHydration, v2 } from "@microsoft/fast-element/hydration.js"; + +enableHydration({ markers: v2 }); +``` + 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 d90852fbfc0..e9e0ede9ebd 100644 --- a/packages/fast-element/SIZES.md +++ b/packages/fast-element/SIZES.md @@ -4,7 +4,7 @@ Bundle sizes for `@microsoft/fast-element` exports. | Export | Minified | Gzip | Brotli | |--------|----------|------|--------| -| CDN Rollup Bundle | 76.80 KB | 22.94 KB | 20.31 KB | +| CDN Rollup Bundle | 77.51 KB | 23.14 KB | 20.53 KB | | FASTElement (@microsoft/fast-element/fast-element.js) | 23.11 KB | 7.12 KB | 6.41 KB | | Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 290 B | | Observable (@microsoft/fast-element/observable.js) | 6.75 KB | 2.51 KB | 2.23 KB | @@ -15,11 +15,11 @@ Bundle sizes for `@microsoft/fast-element` exports. | slotted (@microsoft/fast-element/slotted.js) | 4.66 KB | 1.81 KB | 1.59 KB | | volatile (@microsoft/fast-element/volatile.js) | 6.84 KB | 2.54 KB | 2.26 KB | | when (@microsoft/fast-element/when.js) | 1.88 KB | 731 B | 589 B | -| html (@microsoft/fast-element/html.js) | 25.94 KB | 8.43 KB | 7.57 KB | -| repeat (@microsoft/fast-element/repeat.js) | 29.84 KB | 9.45 KB | 8.52 KB | +| html (@microsoft/fast-element/html.js) | 26.64 KB | 8.64 KB | 7.77 KB | +| repeat (@microsoft/fast-element/repeat.js) | 30.54 KB | 9.66 KB | 8.71 KB | | css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B | -| enableHydration (@microsoft/fast-element/hydration.js) | 44.74 KB | 13.39 KB | 12.05 KB | -| declarativeTemplate (@microsoft/fast-element/declarative.js) | 60.36 KB | 18.91 KB | 16.99 KB | +| enableHydration (@microsoft/fast-element/hydration.js) | 45.49 KB | 13.62 KB | 12.26 KB | +| declarativeTemplate (@microsoft/fast-element/declarative.js) | 61.07 KB | 19.12 KB | 17.13 KB | | ArrayObserver (@microsoft/fast-element/arrays.js) | 12.55 KB | 4.46 KB | 4.03 KB | | observerMap (@microsoft/fast-element/observer-map.js) | 21.96 KB | 7.73 KB | 6.97 KB | | attributeMap (@microsoft/fast-element/attribute-map.js) | 15.31 KB | 5.41 KB | 4.88 KB | diff --git a/packages/fast-element/docs/declarative/design.md b/packages/fast-element/docs/declarative/design.md index c03fdbccb52..a6bde3f566c 100644 --- a/packages/fast-element/docs/declarative/design.md +++ b/packages/fast-element/docs/declarative/design.md @@ -242,7 +242,7 @@ packages/fast-element/ │ ├── components/ │ │ ├── schema.ts # Shared Schema class + schemaRegistry │ │ └── definition-schema-transforms.ts # Definition-scoped schema transform storage -│ └── declarative/ +│ ├── declarative/ │ ├── index.ts # Public declarative entrypoint implementation │ ├── interfaces.ts # Message enum (error codes) │ ├── debug.ts # Human-readable declarative debug messages @@ -254,6 +254,9 @@ packages/fast-element/ │ ├── observer-map-utilities.ts # Shared observer-map helpers │ ├── utilities.ts # Declarative parsing helpers │ └── syntax.ts # Syntax delimiter constants +│ └── hydration/ +│ ├── markers.ts # Default 3.x marker reader and marker strategy contract +│ └── markers_v2.ts # Opt-in 2.x indexed marker reader ├── scripts/ │ └── declarative/ # Fixture build + webui integration scripts └── test/ @@ -320,6 +323,11 @@ Primary declarative exports intended for application code: | `Schema` | `@microsoft/fast-element/schema.js` JSON schema builder that records binding paths discovered during template parsing. Each instance owns its own schema map and registers itself in the `schemaRegistry` for cross-element `$ref` resolution. | | `schemaRegistry` | `@microsoft/fast-element/schema.js` module-level `Map>` that indexes schemas by custom element name. Used for cross-element lookups (e.g. nested component `$ref` resolution). | +Hydration marker selection is available from +`@microsoft/fast-element/hydration.js`. FAST Element 3.x data-free markers are +the default; `enableHydration({ markers: v2 })` opts into the FAST Element 2.x +indexed marker reader for staged server/client migrations. + Primary map extension exports: | Export | Import path | Purpose | @@ -701,6 +709,12 @@ With `declarativeTemplate()`, connection gating happens before platform registra ### Hydration marker formats +`enableHydration()` uses the FAST Element 3.x data-free marker reader by +default. The marker strategy is shared by target discovery and nested repeat +hydration so a view uses one consistent format throughout its hydration walk. +The optional `v2` strategy contains the legacy indexed-marker parsing logic and +is selected with `enableHydration({ markers: v2 })`. + **Content bindings** use HTML comments (data-free, matched by string equality): ``` diff --git a/packages/fast-element/docs/declarative/syntax.md b/packages/fast-element/docs/declarative/syntax.md index f2f7fff4b9b..b23572e039c 100644 --- a/packages/fast-element/docs/declarative/syntax.md +++ b/packages/fast-element/docs/declarative/syntax.md @@ -97,6 +97,16 @@ enableHydration({ When `StopHydration.never` is used, `enableHydration().whenHydrated()` intentionally remains pending because hydration has no global completion point. +FAST Element 3.x data-free hydration markers are selected by default. Existing +server output that uses FAST Element 2.x indexed markers can be hydrated by +selecting the v2 marker reader: + +```typescript +import { enableHydration, v2 } from "@microsoft/fast-element/hydration.js"; + +enableHydration({ markers: v2 }); +``` + ## `observerMap` When the `observerMap()` extension is applied to an element definition, diff --git a/packages/fast-element/src/components/enable-hydration.ts b/packages/fast-element/src/components/enable-hydration.ts index fa72e066807..3b67af7e85f 100644 --- a/packages/fast-element/src/components/enable-hydration.ts +++ b/packages/fast-element/src/components/enable-hydration.ts @@ -1,4 +1,5 @@ import { installHydrationDiagnostic } from "../hydration/diagnostics.js"; +import { installHydrationMarkers } from "../hydration/markers.js"; import { ensureHydrationRuntime } from "../hydration/runtime.js"; import type { Mutable } from "../interfaces.js"; import { SourceLifetime } from "../observation/observable.js"; @@ -51,6 +52,10 @@ let hookInstalled = false; * `HydrationTargetElementError`. The debugger module is tree-shaken * out of production hydration bundles unless explicitly imported. * + * Pass `markers: v2` when hydrating server output that still uses FAST + * Element 2.x indexed hydration markers. FAST Element 3.x data-free markers + * remain the default. + * * @example * ```ts * import { enableHydration } from "@microsoft/fast-element/hydration.js"; @@ -77,12 +82,23 @@ let hookInstalled = false; * enableHydration({ debugger: hydrationDebugger() }); * ``` * + * @example FAST Element 2.x hydration markers + * ```ts + * import { enableHydration, v2 } from "@microsoft/fast-element/hydration.js"; + * + * enableHydration({ markers: v2 }); + * ``` + * * @param options - Optional hydration behavior. * @public */ export function enableHydration(options?: HydrationOptions): HydrationController { ensureHydrationRuntime(); + if (options?.markers) { + installHydrationMarkers(options.markers); + } + if (options?.debugger) { installHydrationDiagnostic(options.debugger.diagnostic); } diff --git a/packages/fast-element/src/components/hydration-tracker.ts b/packages/fast-element/src/components/hydration-tracker.ts index 82d1274c4be..e26ed9eb359 100644 --- a/packages/fast-element/src/components/hydration-tracker.ts +++ b/packages/fast-element/src/components/hydration-tracker.ts @@ -1,4 +1,5 @@ import type { HydrationDebugger } from "../hydration/hydration-debugger.js"; +import type { HydrationMarkers } from "../hydration/markers.js"; import { FASTElementDefinition } from "./fast-definitions.js"; /** @@ -27,6 +28,15 @@ export type StopHydration = (typeof StopHydration)[keyof typeof StopHydration]; * @public */ export interface HydrationOptions { + /** + * The marker format used by the server-rendered HTML. + * The selected format applies to all subsequent hydration on the page; + * later calls that omit this option retain the current selection. + * + * @defaultValue FAST Element 3.x data-free markers + */ + markers?: HydrationMarkers; + /** * Indicates when the hydration hook should stop handling new * prerendered elements. diff --git a/packages/fast-element/src/components/hydration.pw.spec.ts b/packages/fast-element/src/components/hydration.pw.spec.ts index 97dda986813..ed913ead0c0 100644 --- a/packages/fast-element/src/components/hydration.pw.spec.ts +++ b/packages/fast-element/src/components/hydration.pw.spec.ts @@ -451,54 +451,215 @@ test.describe("The prerendered content optimization", () => { expect(result.spanCount).toBe(3); }); - test("does not recognize legacy hydration markers", async ({ page }) => { + test("uses v3 markers by default and recognizes v2 markers only through the v2 reader", async ({ + page, + }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { HydrationMarkup, + v2, // @ts-expect-error: Client module. } = await import("/main.js"); - const element = document.createElement("div"); - element.setAttribute("data-fe-b", "0 1"); - element.setAttribute("data-fe-b-2", ""); - element.setAttribute("data-fe-c-3-2", ""); + const defaultElement = document.createElement("div"); + defaultElement.setAttribute("data-fe-b", "0 1"); + defaultElement.setAttribute("data-fe-b-2", ""); + defaultElement.setAttribute("data-fe-c-3-2", ""); + + const v2Element = document.createElement("div"); + v2Element.setAttribute("data-fe-b", "0 1"); + const v2Attributes = v2.resolveAttributeBindings(v2Element, 2, 2); + v2Attributes?.cleanup?.(); return { - attributeCount: HydrationMarkup.parseAttributeBindingCount(element), - contentEnd: HydrationMarkup.isContentBindingEndMarker( - "fe-b$$end$$0$$scope$$fe-b", - ), - contentStart: HydrationMarkup.isContentBindingStartMarker( - "fe-b$$start$$0$$scope$$fe-b", - ), - elementEnd: HydrationMarkup.isElementBoundaryEndMarker( - document.createComment("fe-eb$$end$$element$$fe-eb"), - ), - elementStart: HydrationMarkup.isElementBoundaryStartMarker( - document.createComment("fe-eb$$start$$element$$fe-eb"), - ), - repeatEnd: HydrationMarkup.isRepeatViewEndMarker( - "fe-repeat$$end$$0$$fe-repeat", - ), - repeatStart: HydrationMarkup.isRepeatViewStartMarker( - "fe-repeat$$start$$0$$fe-repeat", - ), + defaults: { + attributeCount: + HydrationMarkup.parseAttributeBindingCount(defaultElement), + contentEnd: HydrationMarkup.isContentBindingEndMarker( + "fe-b$$end$$0$$scope$$fe-b", + ), + contentStart: HydrationMarkup.isContentBindingStartMarker( + "fe-b$$start$$0$$scope$$fe-b", + ), + elementEnd: HydrationMarkup.isElementBoundaryEndMarker( + document.createComment("fe-eb$$end$$element$$fe-eb"), + ), + elementStart: HydrationMarkup.isElementBoundaryStartMarker( + document.createComment("fe-eb$$start$$element$$fe-eb"), + ), + repeatEnd: HydrationMarkup.isRepeatViewEndMarker( + "fe-repeat$$end$$0$$fe-repeat", + ), + repeatStart: HydrationMarkup.isRepeatViewStartMarker( + "fe-repeat$$start$$0$$fe-repeat", + ), + }, + v2: { + attributeIndices: v2Attributes?.factoryIndices, + attributeMarkersRemoved: v2Element.getAttributeNames().length === 0, + contentEnd: v2.isContentBindingEndMarker("fe-b$$end$$0$$scope$$fe-b"), + contentStart: v2.isContentBindingStartMarker( + "fe-b$$start$$0$$scope$$fe-b", + ), + elementEnd: v2.isElementBoundaryEndMarker( + document.createComment("fe-eb$$end$$element$$fe-eb"), + ), + elementStart: v2.isElementBoundaryStartMarker( + document.createComment("fe-eb$$start$$element$$fe-eb"), + ), + repeatEnd: v2.isRepeatViewEndMarker("fe-repeat$$end$$0$$fe-repeat"), + repeatStart: v2.isRepeatViewStartMarker( + "fe-repeat$$start$$0$$fe-repeat", + ), + }, }; }); expect(result).toEqual({ - attributeCount: null, - contentEnd: false, - contentStart: false, - elementEnd: false, - elementStart: false, - repeatEnd: false, - repeatStart: false, + defaults: { + attributeCount: null, + contentEnd: false, + contentStart: false, + elementEnd: false, + elementStart: false, + repeatEnd: false, + repeatStart: false, + }, + v2: { + attributeIndices: [2, 3], + attributeMarkersRemoved: true, + contentEnd: true, + contentStart: true, + elementEnd: true, + elementStart: true, + repeatEnd: true, + repeatStart: true, + }, }); }); + test("hydrates legacy indexed repeat markers when configured", async ({ page }) => { + await page.goto("/"); + + const element = await page.evaluateHandle(async () => { + const { + enableHydration, + FASTElement, + FASTElementDefinition, + html, + repeat, + uniqueElementName, + v2, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + enableHydration({ markers: v2 }); + const name = uniqueElementName(); + + class TestElement extends FASTElement { + items = ["one", "two"]; + + static definition = { + name, + template: html` + ${repeat((x: { items: any }) => x.items, html`${(x: any) => x}`)} + `, + }; + } + + await (await FASTElementDefinition.compose(TestElement)).define(); + + const container = document.createElement("div"); + document.body.appendChild(container); + (container as any).setHTMLUnsafe( + `<${name}>`, + ); + + return container.firstElementChild; + }); + + const result = await element.evaluate(async (element: any) => { + await element.$fastController.isHydrated; + await new Promise(resolve => requestAnimationFrame(resolve)); + + return { + text: element.shadowRoot?.textContent?.replace(/\s+/g, "") ?? "", + spanCount: element.shadowRoot?.querySelectorAll("span").length ?? 0, + }; + }); + + expect(result.text).toBe("onetwo"); + expect(result.spanCount).toBe(2); + }); + + test("hydrates legacy indexed attribute markers when configured", async ({ + page, + }) => { + await page.goto("/"); + + const element = await page.evaluateHandle(async () => { + const { + enableHydration, + FASTElement, + FASTElementDefinition, + html, + ref, + uniqueElementName, + v2, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + enableHydration({ markers: v2 }); + const name = uniqueElementName(); + + class TestElement extends FASTElement { + count = 0; + disabled = false; + input!: HTMLInputElement; + + static definition = { + name, + template: html` + + + `, + }; + } + + await (await FASTElementDefinition.compose(TestElement)).define(); + + const container = document.createElement("div"); + document.body.appendChild(container); + (container as any).setHTMLUnsafe( + `<${name}>`, + ); + + return container.firstElementChild; + }); + + const result = await element.evaluate(async (element: any) => { + await element.$fastController.isHydrated; + await new Promise(resolve => requestAnimationFrame(resolve)); + element.shadowRoot!.querySelector("button")!.click(); + + return { + count: element.count, + inputResolved: + element.input === element.shadowRoot!.querySelector("input"), + }; + }); + + expect(result.count).toBe(1); + expect(result.inputResolved).toBe(true); + }); + test("should remove extra repeat ranges when SSR rendered more items", async ({ page, }) => { diff --git a/packages/fast-element/src/components/hydration.ts b/packages/fast-element/src/components/hydration.ts index a2d4c9446e8..20d000e8751 100644 --- a/packages/fast-element/src/components/hydration.ts +++ b/packages/fast-element/src/components/hydration.ts @@ -1,5 +1,3 @@ -import { Message } from "../interfaces.js"; -import { FAST } from "../platform.js"; import type { ContentTemplate, HydratableContentTemplate, @@ -13,116 +11,7 @@ import type { SyntheticViewTemplate, } from "../templating/template.js"; -/** - * Data-free sequential hydration markers. - * - * All markers use the `fe:` prefix to namespace them to FAST Element. The closing - * marker uses `/` following HTML/XML convention. Markers carry zero - * embedded data — the hydration walker derives factory-to-node mappings - * by maintaining a sequential pointer through the factories array. - * - * Content binding markers bracket text/template content: - * ...content... - * - * Repeat item markers bracket each repeated item: - * ...item... - * - * Element boundary markers demarcate nested custom elements: - * ...shadow content... - * - * Attribute bindings use a single `data-fe` attribute whose value is - * the count of attribute binding factories targeting the element: - *
(3 attribute bindings) - */ - -function isComment(node: Node): node is Comment { - return node && node.nodeType === Node.COMMENT_NODE; -} - -/** - * Markup utilities to aid in template hydration. - * @internal - */ -export const HydrationMarkup = Object.freeze({ - // Single attribute marker format (count only) - attributeMarkerName: "data-fe", - - // Content binding markers (no arguments) - contentBindingStartMarker(): string { - return "fe:b"; - }, - contentBindingEndMarker(): string { - return "fe:/b"; - }, - - // Repeat item markers (no arguments) - repeatStartMarker(): string { - return "fe:r"; - }, - repeatEndMarker(): string { - return "fe:/r"; - }, - - // Element boundary markers (no arguments) - elementBoundaryStartMarker(): string { - return "fe:e"; - }, - elementBoundaryEndMarker(): string { - return "fe:/e"; - }, - - // Detection — simple string equality - isContentBindingStartMarker(data: string): boolean { - return data === "fe:b"; - }, - isContentBindingEndMarker(data: string): boolean { - return data === "fe:/b"; - }, - isRepeatViewStartMarker(data: string): boolean { - return data === "fe:r"; - }, - isRepeatViewEndMarker(data: string): boolean { - return data === "fe:/r"; - }, - isElementBoundaryStartMarker(node: Node): boolean { - return isComment(node) && node.data === "fe:e"; - }, - isElementBoundaryEndMarker(node: Node): boolean { - return isComment(node) && node.data === "fe:/e"; - }, - - /** - * Returns the count of attribute bindings on the element, or null - * if no attribute binding marker is present. - * - * Parses the `data-fe="N"` attribute format where N is the count - * of attribute binding factories targeting this element. - */ - parseAttributeBindingCount(node: Element): number | null { - const attr = node.getAttribute(this.attributeMarkerName); - if (attr === null) { - return null; - } - - const trimmed = attr.trim(); - - if (!/^\d+$/.test(trimmed)) { - throw FAST.error(Message.invalidHydrationAttributeMarker, { - value: attr, - }); - } - - const count = parseInt(trimmed, 10); - - if (count < 1) { - throw FAST.error(Message.invalidHydrationAttributeMarker, { - value: attr, - }); - } - - return count; - }, -}); +export { HydrationMarkup } from "../hydration/markers.js"; /** * @internal diff --git a/packages/fast-element/src/hydration/exports.ts b/packages/fast-element/src/hydration/exports.ts index 4a3d662c718..ff07fdfe08c 100644 --- a/packages/fast-element/src/hydration/exports.ts +++ b/packages/fast-element/src/hydration/exports.ts @@ -68,6 +68,8 @@ export type { } from "./diagnostics.js"; export type { HydrationDebugger } from "./hydration-debugger.js"; export { hydrationDebugger } from "./hydration-debugger.js"; +export type { HydrationMarkers } from "./markers.js"; +export { v2 } from "./markers_v2.js"; export type { ViewBehaviorBoundaries, ViewBoundaries, diff --git a/packages/fast-element/src/hydration/markers.ts b/packages/fast-element/src/hydration/markers.ts new file mode 100644 index 00000000000..6b8e1aba025 --- /dev/null +++ b/packages/fast-element/src/hydration/markers.ts @@ -0,0 +1,203 @@ +import { Message } from "../interfaces.js"; +import { FAST } from "../platform.js"; + +const hydrationMarkersBrand: unique symbol = Symbol(); + +/** + * Identifies a supported hydration marker format. + * @public + */ +export interface HydrationMarkers { + readonly [hydrationMarkersBrand]: true; +} + +/** + * Resolves hydration marker metadata to compiled factory indices. + * @internal + */ +export interface HydrationMarkerResolution { + factoryIndices: readonly number[]; + nextFactoryPointer: number; + cleanup?(): void; +} + +/** + * Defines how server-rendered hydration markers are interpreted. + * @internal + */ +export interface HydrationMarkerStrategy extends HydrationMarkers { + expectedContentAfterStartMarker: string; + expectedContentEndMarker: string; + expectedElementBoundaryEndMarker: string; + resolveAttributeBindings( + node: Element, + factoryPointer: number, + hydrationIndexOffset: number, + ): HydrationMarkerResolution | null; + resolveContentBinding( + data: string, + factoryPointer: number, + hydrationIndexOffset: number, + ): HydrationMarkerResolution | null; + isContentBindingStartMarker(data: string): boolean; + isContentBindingEndMarker(data: string): boolean; + isRepeatViewStartMarker(data: string): boolean; + isRepeatViewEndMarker(data: string): boolean; + isElementBoundaryStartMarker(node: Node): boolean; + isElementBoundaryEndMarker(node: Node): boolean; +} + +type HydrationMarkerImplementation = Omit< + HydrationMarkerStrategy, + typeof hydrationMarkersBrand +>; + +const markerStrategies = new WeakMap(); + +/** + * Creates a registered hydration marker strategy. + * @internal + */ +export function createHydrationMarkers< + TImplementation extends HydrationMarkerImplementation, +>(implementation: TImplementation): HydrationMarkers & TImplementation { + const markers = Object.freeze({ + ...implementation, + [hydrationMarkersBrand]: true as const, + }); + markerStrategies.set(markers, markers); + return markers; +} + +function isComment(node: Node): node is Comment { + return node.nodeType === Node.COMMENT_NODE; +} + +function parseAttributeBindingCount(node: Element): number | null { + const attr = node.getAttribute("data-fe"); + if (attr === null) { + return null; + } + + const trimmed = attr.trim(); + + if (!/^\d+$/.test(trimmed)) { + throw FAST.error(Message.invalidHydrationAttributeMarker, { + value: attr, + }); + } + + const count = parseInt(trimmed, 10); + + if (count < 1) { + throw FAST.error(Message.invalidHydrationAttributeMarker, { + value: attr, + }); + } + + return count; +} + +/** + * Data-free sequential hydration markers used by FAST Element 3.x. + * @internal + */ +export const HydrationMarkup = createHydrationMarkers({ + attributeMarkerName: "data-fe", + expectedContentAfterStartMarker: + "content following `` content binding marker", + expectedContentEndMarker: "matching `` content binding close marker", + expectedElementBoundaryEndMarker: + "matching `` element boundary close marker", + + contentBindingStartMarker(): string { + return "fe:b"; + }, + contentBindingEndMarker(): string { + return "fe:/b"; + }, + repeatStartMarker(): string { + return "fe:r"; + }, + repeatEndMarker(): string { + return "fe:/r"; + }, + elementBoundaryStartMarker(): string { + return "fe:e"; + }, + elementBoundaryEndMarker(): string { + return "fe:/e"; + }, + parseAttributeBindingCount, + resolveAttributeBindings( + node: Element, + factoryPointer: number, + _hydrationIndexOffset: number, + ): HydrationMarkerResolution | null { + const count = parseAttributeBindingCount(node); + if (count === null) { + return null; + } + + const factoryIndices = Array.from( + { length: count }, + (_, index) => factoryPointer + index, + ); + + return { + factoryIndices, + nextFactoryPointer: factoryPointer + count, + cleanup: () => node.removeAttribute("data-fe"), + }; + }, + resolveContentBinding( + data: string, + factoryPointer: number, + _hydrationIndexOffset: number, + ): HydrationMarkerResolution | null { + if (data !== "fe:b") { + return null; + } + + return { + factoryIndices: [factoryPointer], + nextFactoryPointer: factoryPointer + 1, + }; + }, + isContentBindingStartMarker(data: string): boolean { + return data === "fe:b"; + }, + isContentBindingEndMarker(data: string): boolean { + return data === "fe:/b"; + }, + isRepeatViewStartMarker(data: string): boolean { + return data === "fe:r"; + }, + isRepeatViewEndMarker(data: string): boolean { + return data === "fe:/r"; + }, + isElementBoundaryStartMarker(node: Node): boolean { + return isComment(node) && node.data === "fe:e"; + }, + isElementBoundaryEndMarker(node: Node): boolean { + return isComment(node) && node.data === "fe:/e"; + }, +}); + +let activeHydrationMarkers = markerStrategies.get(HydrationMarkup)!; + +/** + * Selects the hydration marker format used by hydratable views. + * @internal + */ +export function installHydrationMarkers(markers: HydrationMarkers): void { + activeHydrationMarkers = markerStrategies.get(markers)!; +} + +/** + * Gets the hydration marker format used by hydratable views. + * @internal + */ +export function getHydrationMarkers(): HydrationMarkerStrategy { + return activeHydrationMarkers; +} diff --git a/packages/fast-element/src/hydration/markers_v2.ts b/packages/fast-element/src/hydration/markers_v2.ts new file mode 100644 index 00000000000..7f48534319a --- /dev/null +++ b/packages/fast-element/src/hydration/markers_v2.ts @@ -0,0 +1,175 @@ +import { Message } from "../interfaces.js"; +import { FAST } from "../platform.js"; +import { + createHydrationMarkers, + type HydrationMarkerResolution, + type HydrationMarkers, +} from "./markers.js"; + +const bindingStartMarker = /fe-b\$\$start\$\$(\d+)\$\$(.+)\$\$fe-b/; +const bindingEndMarker = /fe-b\$\$end\$\$(\d+)\$\$(.+)\$\$fe-b/; +const repeatViewStartMarker = /fe-repeat\$\$start\$\$(\d+)\$\$fe-repeat/; +const repeatViewEndMarker = /fe-repeat\$\$end\$\$(\d+)\$\$fe-repeat/; +const elementBoundaryStartMarker = /^(?:.{0,1000})fe-eb\$\$start\$\$(.+?)\$\$fe-eb/; +const elementBoundaryEndMarker = /fe-eb\$\$end\$\$(.{0,1000})\$\$fe-eb(?:.{0,1000})$/; +const attributeMarkerName = "data-fe-b"; +const compactAttributeMarkerName = "data-fe-c"; + +function isComment(node: Node): node is Comment { + return node.nodeType === Node.COMMENT_NODE; +} + +function parseMarkerIndex(pattern: RegExp, data: string): number | null { + const match = pattern.exec(data); + return match === null ? null : Number(match[1]); +} + +function parseAttributeBindingIndices(node: Element): number[] | null { + const indices: number[] = []; + const attr = node.getAttribute(attributeMarkerName); + + if (attr !== null) { + for (const value of attr.trim().split(/\s+/)) { + if (value === "") { + continue; + } + + const index = Number(value); + + if (!Number.isInteger(index) || index < 0) { + throw FAST.error(Message.invalidHydrationAttributeMarker, { + value: attr, + }); + } + + indices.push(index); + } + } + + const enumeratedPrefix = `${attributeMarkerName}-`; + const compactPrefix = `${compactAttributeMarkerName}-`; + + for (const name of node.getAttributeNames()) { + if (name.startsWith(enumeratedPrefix)) { + const index = Number(name.slice(enumeratedPrefix.length)); + + if (!Number.isInteger(index) || index < 0) { + throw FAST.error(Message.invalidHydrationAttributeMarker, { + value: name, + }); + } + + indices.push(index); + } else if (name.startsWith(compactPrefix)) { + const [start, count] = name + .slice(compactPrefix.length) + .split("-") + .map(value => Number(value)); + + if ( + !Number.isInteger(start) || + !Number.isInteger(count) || + start < 0 || + count < 1 + ) { + throw FAST.error(Message.invalidHydrationAttributeMarker, { + value: name, + }); + } + + for (let i = 0; i < count; i++) { + indices.push(start + i); + } + } + } + + return indices.length === 0 ? null : indices; +} + +function removeAttributeBindingMarkers(node: Element): void { + node.removeAttribute(attributeMarkerName); + + for (const name of node.getAttributeNames()) { + if ( + name.startsWith(`${attributeMarkerName}-`) || + name.startsWith(`${compactAttributeMarkerName}-`) + ) { + node.removeAttribute(name); + } + } +} + +/** + * FAST Element 2.x indexed hydration markers. + * + * @example + * ```ts + * import { enableHydration, v2 } from "@microsoft/fast-element/hydration.js"; + * + * enableHydration({ markers: v2 }); + * ``` + * @public + */ +export const v2: HydrationMarkers = createHydrationMarkers({ + expectedContentAfterStartMarker: + "content following a `fe-b$$start$$...$$fe-b` content binding marker", + expectedContentEndMarker: + "matching `fe-b$$end$$...$$fe-b` content binding close marker", + expectedElementBoundaryEndMarker: + "matching `fe-eb$$end$$...$$fe-eb` element boundary close marker", + resolveAttributeBindings( + node: Element, + factoryPointer: number, + hydrationIndexOffset: number, + ): HydrationMarkerResolution | null { + const indices = parseAttributeBindingIndices(node); + if (indices === null) { + return null; + } + + const factoryIndices = indices.map(index => index + hydrationIndexOffset); + return { + factoryIndices, + nextFactoryPointer: Math.max( + factoryPointer, + ...factoryIndices.map(index => index + 1), + ), + cleanup: () => removeAttributeBindingMarkers(node), + }; + }, + resolveContentBinding( + data: string, + factoryPointer: number, + hydrationIndexOffset: number, + ): HydrationMarkerResolution | null { + const index = parseMarkerIndex(bindingStartMarker, data); + if (index === null) { + return null; + } + + const factoryIndex = index + hydrationIndexOffset; + + return { + factoryIndices: [factoryIndex], + nextFactoryPointer: Math.max(factoryPointer, factoryIndex + 1), + }; + }, + isContentBindingStartMarker(data: string): boolean { + return bindingStartMarker.test(data); + }, + isContentBindingEndMarker(data: string): boolean { + return bindingEndMarker.test(data); + }, + isRepeatViewStartMarker(data: string): boolean { + return repeatViewStartMarker.test(data); + }, + isRepeatViewEndMarker(data: string): boolean { + return repeatViewEndMarker.test(data); + }, + isElementBoundaryStartMarker(node: Node): boolean { + return isComment(node) && elementBoundaryStartMarker.test(node.data); + }, + isElementBoundaryEndMarker(node: Node): boolean { + return isComment(node) && elementBoundaryEndMarker.test(node.data); + }, +}); diff --git a/packages/fast-element/src/hydration/messages.ts b/packages/fast-element/src/hydration/messages.ts index 93b4ce319d1..cd9b09f598b 100644 --- a/packages/fast-element/src/hydration/messages.ts +++ b/packages/fast-element/src/hydration/messages.ts @@ -41,10 +41,7 @@ export function formatAspect(label: string, sourceAspect: string | undefined): s * `" with content binding"` or `"content binding"` when no tag is * associated with the binding factory. */ -export function formatExpectedTarget( - tagName: string | null, - aspect: string, -): string { +export function formatExpectedTarget(tagName: string | null, aspect: string): string { return tagName ? `<${tagName.toLowerCase()}> with ${aspect} binding` : `${aspect} binding`; @@ -84,17 +81,6 @@ export function formatRichMismatchMessage( ); } -// -- Structural expectations (used by target-builder.ts throw sites) --------- - -export const expectedContentAfterStartMarker = - "content following `` content binding marker"; - -export const expectedContentEndMarker = - "matching `` content binding close marker"; - -export const expectedElementBoundaryEndMarker = - "matching `` element boundary close marker"; - /** * Builds the "no more attribute bindings" structural expectation message * thrown when an element's `data-fe` count claims more attribute bindings diff --git a/packages/fast-element/src/hydration/target-builder.ts b/packages/fast-element/src/hydration/target-builder.ts index e2a281ae2f3..f8955ea2884 100644 --- a/packages/fast-element/src/hydration/target-builder.ts +++ b/packages/fast-element/src/hydration/target-builder.ts @@ -1,4 +1,3 @@ -import { HydrationMarkup } from "../components/hydration.js"; import type { CompiledViewBehaviorFactory, ViewBehaviorFactory, @@ -10,10 +9,8 @@ import { type HydrationMismatchActual, type HydrationMismatchExpectation, } from "./diagnostics.js"; +import { getHydrationMarkers, type HydrationMarkerStrategy } from "./markers.js"; import { - expectedContentAfterStartMarker, - expectedContentEndMarker, - expectedElementBoundaryEndMarker, formatNoMoreAttributeBindings, formatNoMoreContentBindings, } from "./messages.js"; @@ -146,6 +143,7 @@ export function buildViewBindingTargets( const targets: ViewBehaviorTargets = {}; const boundaries: ViewBehaviorBoundaries = {}; + const markers = getHydrationMarkers(); // Sequential factory pointer — skip host bindings at the start const hydrationIndexOffset = getHydrationIndexOffset(factories); @@ -156,11 +154,16 @@ export function buildViewBindingTargets( while (node !== null) { switch (node.nodeType) { case Node.ELEMENT_NODE: { - const element = node as Element; - const count = HydrationMarkup.parseAttributeBindingCount(element); - if (count !== null) { - for (let i = 0; i < count; i++) { - const factory = factories[factoryPointer++]; + const resolution = markers.resolveAttributeBindings( + node as Element, + factoryPointer, + hydrationIndexOffset, + ); + if (resolution !== null) { + factoryPointer = resolution.nextFactoryPointer; + + for (const factoryIndex of resolution.factoryIndices) { + const factory = factories[factoryIndex]; if (!factory) { const expected = formatNoMoreAttributeBindings( factories.length, @@ -180,21 +183,30 @@ export function buildViewBindingTargets( } targetFactory(factory, node, targets); } - element.removeAttribute(HydrationMarkup.attributeMarkerName); + + resolution.cleanup?.(); } break; } case Node.COMMENT_NODE: { const data = (node as Comment).data; - if (HydrationMarkup.isElementBoundaryStartMarker(node)) { + if (markers.isElementBoundaryStartMarker(node)) { // Element boundary — clear start marker and skip subtree (node as Comment).data = ""; - skipToElementBoundaryEnd(walker, factories, node); - } else if (HydrationMarkup.isContentBindingStartMarker(data)) { - // Content binding — consume next factory - const factory = factories[factoryPointer++]; + skipToElementBoundaryEnd(walker, factories, node, markers); + } else { + const resolution = markers.resolveContentBinding( + data, + factoryPointer, + hydrationIndexOffset, + ); + if (resolution === null) { + break; + } + factoryPointer = resolution.nextFactoryPointer; + const factory = factories[resolution.factoryIndices[0]]; if (!factory) { const expected = formatNoMoreContentBindings(factories.length); const result = getHydrationDiagnostic().formatStructuralError( @@ -217,6 +229,7 @@ export function buildViewBindingTargets( factories, targets, boundaries, + markers, ); } break; @@ -237,13 +250,14 @@ function targetContentBinding( factories: CompiledViewBehaviorFactory[], targets: ViewBehaviorTargets, boundaries: ViewBehaviorBoundaries, + markers: HydrationMarkerStrategy, ) { const nodes: Node[] = []; let current: Node | null = walker.nextSibling(); node.data = ""; if (current === null) { - const expected = expectedContentAfterStartMarker; + const expected = markers.expectedContentAfterStartMarker; const result = getHydrationDiagnostic().formatStructuralError( node, getHostName(node), @@ -264,9 +278,9 @@ function targetContentBinding( let depth = 0; while (current !== null) { if (isComment(current)) { - if (HydrationMarkup.isContentBindingStartMarker(current.data)) { + if (markers.isContentBindingStartMarker(current.data)) { depth++; - } else if (HydrationMarkup.isContentBindingEndMarker(current.data)) { + } else if (markers.isContentBindingEndMarker(current.data)) { if (depth === 0) break; depth--; } @@ -276,7 +290,7 @@ function targetContentBinding( } if (current === null) { - const expected = expectedContentEndMarker; + const expected = markers.expectedContentEndMarker; const result = getHydrationDiagnostic().formatStructuralError( node, getHostName(node), @@ -322,15 +336,16 @@ function skipToElementBoundaryEnd( walker: TreeWalker, factories: CompiledViewBehaviorFactory[], startNode: Node, + markers: HydrationMarkerStrategy, ) { let depth = 0; let current = walker.nextSibling(); while (current !== null) { if (isComment(current)) { - if (HydrationMarkup.isElementBoundaryStartMarker(current)) { + if (markers.isElementBoundaryStartMarker(current)) { current.data = ""; depth++; - } else if (HydrationMarkup.isElementBoundaryEndMarker(current)) { + } else if (markers.isElementBoundaryEndMarker(current)) { if (depth === 0) { current.data = ""; return; @@ -342,7 +357,7 @@ function skipToElementBoundaryEnd( current = walker.nextSibling(); } - const expected = expectedElementBoundaryEndMarker; + const expected = markers.expectedElementBoundaryEndMarker; const result = getHydrationDiagnostic().formatStructuralError( startNode, getHostName(startNode), diff --git a/packages/fast-element/src/templating/repeat.ts b/packages/fast-element/src/templating/repeat.ts index 66019b15447..908cd31ca9d 100644 --- a/packages/fast-element/src/templating/repeat.ts +++ b/packages/fast-element/src/templating/repeat.ts @@ -1,6 +1,7 @@ import type { Binding, BindingDirective } from "../binding/binding.js"; import { normalizeBinding } from "../binding/normalize.js"; -import { HydrationMarkup, isHydratable } from "../components/hydration.js"; +import { isHydratable } from "../components/hydration.js"; +import { getHydrationMarkers } from "../hydration/markers.js"; import { ArrayObserver, type Sort, type Splice } from "../observation/arrays.js"; import type { Notifier, Subscriber } from "../observation/notifier.js"; import { @@ -433,6 +434,7 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { const items = this.items; const itemCount = items.length; const views = (this.views = new Array(itemCount)); + const markers = getHydrationMarkers(); // First pass: collect all repeat marker pairs by walking backward. // Each entry tracks both the item content range and its SSR markers. @@ -440,10 +442,7 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { let current: Node | null = this.location.previousSibling; while (current !== null) { - if ( - !isCommentNode(current) || - !HydrationMarkup.isRepeatViewEndMarker(current.data) - ) { + if (!isCommentNode(current) || !markers.isRepeatViewEndMarker(current.data)) { current = current.previousSibling; continue; } @@ -464,9 +463,9 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { let depth = 0; while (start !== null) { if (isCommentNode(start)) { - if (HydrationMarkup.isRepeatViewEndMarker(start.data)) { + if (markers.isRepeatViewEndMarker(start.data)) { depth++; - } else if (HydrationMarkup.isRepeatViewStartMarker(start.data)) { + } else if (markers.isRepeatViewStartMarker(start.data)) { if (depth === 0) { const startMarker = start; startMarker.data = ""; diff --git a/packages/fast-element/test/main.ts b/packages/fast-element/test/main.ts index 4dc195ba11f..c36a4776a95 100644 --- a/packages/fast-element/test/main.ts +++ b/packages/fast-element/test/main.ts @@ -46,6 +46,7 @@ export { export { DOM, DOMAspect } from "../src/dom.js"; export { DOMPolicy } from "../src/dom-policy.js"; export { hydrationDebugger } from "../src/hydration/hydration-debugger.js"; +export { v2 } from "../src/hydration/markers_v2.js"; export { Observable, observable } from "../src/observation/observable.js"; export { Updates } from "../src/observation/update-queue.js"; export { volatile } from "../src/observation/volatile.js"; diff --git a/sites/website/src/docs/3.x/migration-guide/hydration.md b/sites/website/src/docs/3.x/migration-guide/hydration.md index f6a1fc4bdc7..1ca82ea378a 100644 --- a/sites/website/src/docs/3.x/migration-guide/hydration.md +++ b/sites/website/src/docs/3.x/migration-guide/hydration.md @@ -40,8 +40,8 @@ Key migration points: 3. `needs-hydration` and `defer-hydration` are no longer required in rendered markup. 4. Hydration callbacks are replaced by hydration promises. -5. SSR hydration marker syntax changed; server and client FAST versions must - match. +5. SSR hydration marker syntax changed. FAST Element 3.x markers are used by + default, with an explicit compatibility option for 2.x server output. ## Install hydration explicitly @@ -201,14 +201,27 @@ If you used `@microsoft/fast-ssr` or custom SSR tooling, update the renderer to new marker format before loading the v3 client. If you use `@microsoft/fast-build`, upgrade it with `@microsoft/fast-element` and rebuild the output. +For a staged migration where the server still emits FAST Element 2.x indexed +markers, configure the v3 client explicitly: + +```ts +import { enableHydration, v2 } from "@microsoft/fast-element/hydration.js"; + +enableHydration({ markers: v2 }); +``` + +The v2 reader accepts the old indexed comment and attribute marker formats. It +does not make a FAST Element 2.x client understand 3.x output, and it does not +provide compatibility for unrelated template or runtime differences. + ## Keep renderer and client versions in sync Hydration succeeds when the HTML produced by the server matches the template and data that the client runtime sees during the element's first render. The -renderer and client both rely on the same depth-first binding order and marker -syntax. Deploy server-rendered output and client bundles together so old marker -output is not hydrated by the v3 client, and the v3 marker output is not loaded -with an older client. +renderer and client both rely on compatible binding order and marker syntax. +Deploy server-rendered output and client bundles together. When a staged +migration requires a v3 client to hydrate v2 marker output, select +`enableHydration({ markers: v2 })`; the default reader expects v3 markers. Do not minify or sanitize away FAST comments or `data-fe` attributes before the client loads. A missing `fe:/b` marker, an invalid `data-fe` count, or a changed @@ -252,6 +265,8 @@ Hydration mismatch in . | `deferHydrationAttribute` | `@microsoft/fast-element/hydration.js` | Legacy `defer-hydration` attribute string for compatibility code. | | `HydrationTracker` | `@microsoft/fast-element/hydration.js` | Standalone hydration lifecycle tracker class. | | `HydrationOptions` | `@microsoft/fast-element/hydration.js` | Type for hydration configuration options. | +| `HydrationMarkers` | `@microsoft/fast-element/hydration.js` | Contract for selecting a hydration marker reader. | +| `v2` | `@microsoft/fast-element/hydration.js` | Opt-in reader for FAST Element 2.x indexed hydration markers. | ## Migration checklist diff --git a/sites/website/src/docs/3.x/resources/export-sizes.md b/sites/website/src/docs/3.x/resources/export-sizes.md index 9c931c59196..d865a60e802 100644 --- a/sites/website/src/docs/3.x/resources/export-sizes.md +++ b/sites/website/src/docs/3.x/resources/export-sizes.md @@ -19,7 +19,7 @@ Bundle sizes for `@microsoft/fast-element` exports. | Export | Minified | Gzip | Brotli | |--------|----------|------|--------| -| CDN Rollup Bundle | 78.79 KB | 23.43 KB | 20.84 KB | +| CDN Rollup Bundle | 77.51 KB | 23.14 KB | 20.53 KB | | FASTElement (@microsoft/fast-element/fast-element.js) | 23.11 KB | 7.12 KB | 6.41 KB | | Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 290 B | | Observable (@microsoft/fast-element/observable.js) | 6.75 KB | 2.51 KB | 2.23 KB | @@ -30,11 +30,11 @@ Bundle sizes for `@microsoft/fast-element` exports. | slotted (@microsoft/fast-element/slotted.js) | 4.66 KB | 1.81 KB | 1.59 KB | | volatile (@microsoft/fast-element/volatile.js) | 6.84 KB | 2.54 KB | 2.26 KB | | when (@microsoft/fast-element/when.js) | 1.88 KB | 731 B | 589 B | -| html (@microsoft/fast-element/html.js) | 27.91 KB | 8.98 KB | 8.04 KB | -| repeat (@microsoft/fast-element/repeat.js) | 31.80 KB | 10.00 KB | 9.03 KB | +| html (@microsoft/fast-element/html.js) | 26.64 KB | 8.64 KB | 7.77 KB | +| repeat (@microsoft/fast-element/repeat.js) | 30.54 KB | 9.66 KB | 8.71 KB | | css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B | -| enableHydration (@microsoft/fast-element/hydration.js) | 46.71 KB | 13.94 KB | 12.51 KB | -| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.33 KB | 19.49 KB | 17.46 KB | +| enableHydration (@microsoft/fast-element/hydration.js) | 45.49 KB | 13.62 KB | 12.26 KB | +| declarativeTemplate (@microsoft/fast-element/declarative.js) | 61.07 KB | 19.12 KB | 17.13 KB | | ArrayObserver (@microsoft/fast-element/arrays.js) | 12.55 KB | 4.46 KB | 4.03 KB | | observerMap (@microsoft/fast-element/observer-map.js) | 21.96 KB | 7.73 KB | 6.97 KB | | attributeMap (@microsoft/fast-element/attribute-map.js) | 15.31 KB | 5.41 KB | 4.88 KB | From 6d6e09c52d6085a5739e57abc2265515f31d970b Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:11:34 -0700 Subject: [PATCH 3/6] chore: use kebab-case for v2 hydration markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c66c1387-9a1b-4386-8d0f-a115ef5706db --- packages/fast-element/docs/declarative/design.md | 2 +- packages/fast-element/src/hydration/exports.ts | 2 +- .../fast-element/src/hydration/{markers_v2.ts => markers-v2.ts} | 0 packages/fast-element/test/main.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/fast-element/src/hydration/{markers_v2.ts => markers-v2.ts} (100%) diff --git a/packages/fast-element/docs/declarative/design.md b/packages/fast-element/docs/declarative/design.md index a6bde3f566c..97cb160822f 100644 --- a/packages/fast-element/docs/declarative/design.md +++ b/packages/fast-element/docs/declarative/design.md @@ -256,7 +256,7 @@ packages/fast-element/ │ └── syntax.ts # Syntax delimiter constants │ └── hydration/ │ ├── markers.ts # Default 3.x marker reader and marker strategy contract -│ └── markers_v2.ts # Opt-in 2.x indexed marker reader +│ └── markers-v2.ts # Opt-in 2.x indexed marker reader ├── scripts/ │ └── declarative/ # Fixture build + webui integration scripts └── test/ diff --git a/packages/fast-element/src/hydration/exports.ts b/packages/fast-element/src/hydration/exports.ts index ff07fdfe08c..cf319b13a3b 100644 --- a/packages/fast-element/src/hydration/exports.ts +++ b/packages/fast-element/src/hydration/exports.ts @@ -69,7 +69,7 @@ export type { export type { HydrationDebugger } from "./hydration-debugger.js"; export { hydrationDebugger } from "./hydration-debugger.js"; export type { HydrationMarkers } from "./markers.js"; -export { v2 } from "./markers_v2.js"; +export { v2 } from "./markers-v2.js"; export type { ViewBehaviorBoundaries, ViewBoundaries, diff --git a/packages/fast-element/src/hydration/markers_v2.ts b/packages/fast-element/src/hydration/markers-v2.ts similarity index 100% rename from packages/fast-element/src/hydration/markers_v2.ts rename to packages/fast-element/src/hydration/markers-v2.ts diff --git a/packages/fast-element/test/main.ts b/packages/fast-element/test/main.ts index c36a4776a95..1df45ac0d3e 100644 --- a/packages/fast-element/test/main.ts +++ b/packages/fast-element/test/main.ts @@ -46,7 +46,7 @@ export { export { DOM, DOMAspect } from "../src/dom.js"; export { DOMPolicy } from "../src/dom-policy.js"; export { hydrationDebugger } from "../src/hydration/hydration-debugger.js"; -export { v2 } from "../src/hydration/markers_v2.js"; +export { v2 } from "../src/hydration/markers-v2.js"; export { Observable, observable } from "../src/observation/observable.js"; export { Updates } from "../src/observation/update-queue.js"; export { volatile } from "../src/observation/volatile.js"; From 3e6111586718f1df6dd85e50a1aa0c289d6f24a9 Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:34:48 -0700 Subject: [PATCH 4/6] feat: generate opt-in v2 hydration markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c66c1387-9a1b-4386-8d0f-a115ef5706db --- ...-728386d6-f5fd-4ae6-bd08-91debe57ed45.json | 7 + ...-18fad60c-d54c-48ef-be11-88bbbada8c2e.json | 2 +- crates/microsoft-fast-build/DESIGN.md | 18 +- crates/microsoft-fast-build/README.md | 14 +- crates/microsoft-fast-build/src/attribute.rs | 7 +- crates/microsoft-fast-build/src/config.rs | 9 + crates/microsoft-fast-build/src/directive.rs | 28 +-- crates/microsoft-fast-build/src/hydration.rs | 63 ++++-- crates/microsoft-fast-build/src/node.rs | 12 +- crates/microsoft-fast-build/src/streaming.rs | 38 ++-- crates/microsoft-fast-build/src/wasm.rs | 34 ++- .../microsoft-fast-build/tests/hydration.rs | 49 +++- packages/fast-build/DESIGN.md | 12 +- packages/fast-build/README.md | 6 +- packages/fast-build/bin/fast.js | 11 +- packages/fast-build/test/config.test.js | 63 ++++++ packages/fast-build/test/wasm-stub.cjs | 2 + packages/fast-element/DESIGN.md | 2 +- packages/fast-element/README.md | 7 +- .../fast-element/docs/declarative/design.md | 6 +- .../fast-element/docs/declarative/syntax.md | 7 +- .../src/components/enable-hydration.ts | 6 +- .../src/components/hydration.pw.spec.ts | 209 ------------------ .../fast-element/src/hydration/exports.ts | 2 +- .../fast-element/src/hydration/markers-v2.ts | 6 +- .../declarative/fixtures/ecosystem/README.md | 1 + .../ecosystem/hydration-markers-v2/entry.html | 14 ++ .../fast-build.config.json | 7 + .../hydration-markers-v2.spec.ts | 56 +++++ .../ecosystem/hydration-markers-v2/index.html | 25 +++ .../ecosystem/hydration-markers-v2/main.ts | 31 +++ .../ecosystem/hydration-markers-v2/state.json | 4 + .../hydration-markers-v2/templates.html | 9 + packages/fast-element/test/main.ts | 2 +- .../src/docs/3.x/migration-guide/hydration.md | 16 +- 35 files changed, 462 insertions(+), 323 deletions(-) create mode 100644 change/@microsoft-fast-build-728386d6-f5fd-4ae6-bd08-91debe57ed45.json create mode 100644 packages/fast-element/test/declarative/fixtures/ecosystem/hydration-markers-v2/entry.html create mode 100644 packages/fast-element/test/declarative/fixtures/ecosystem/hydration-markers-v2/fast-build.config.json create mode 100644 packages/fast-element/test/declarative/fixtures/ecosystem/hydration-markers-v2/hydration-markers-v2.spec.ts create mode 100644 packages/fast-element/test/declarative/fixtures/ecosystem/hydration-markers-v2/index.html create mode 100644 packages/fast-element/test/declarative/fixtures/ecosystem/hydration-markers-v2/main.ts create mode 100644 packages/fast-element/test/declarative/fixtures/ecosystem/hydration-markers-v2/state.json create mode 100644 packages/fast-element/test/declarative/fixtures/ecosystem/hydration-markers-v2/templates.html diff --git a/change/@microsoft-fast-build-728386d6-f5fd-4ae6-bd08-91debe57ed45.json b/change/@microsoft-fast-build-728386d6-f5fd-4ae6-bd08-91debe57ed45.json new file mode 100644 index 00000000000..0ed007ad4fd --- /dev/null +++ b/change/@microsoft-fast-build-728386d6-f5fd-4ae6-bd08-91debe57ed45.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add a markers_v2 option for emitting FAST Element 2.x indexed hydration markers.", + "packageName": "@microsoft/fast-build", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json b/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json index 5669f711ea5..9bc31cce1dc 100644 --- a/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json +++ b/change/@microsoft-fast-element-18fad60c-d54c-48ef-be11-88bbbada8c2e.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "Add opt-in hydration support for FAST Element 2.x indexed markers.", + "comment": "Add an opt-in markers_v2 reader for FAST Element 2.x indexed hydration markers.", "packageName": "@microsoft/fast-element", "email": "7559015+janechu@users.noreply.github.com", "dependentChangeType": "none" diff --git a/crates/microsoft-fast-build/DESIGN.md b/crates/microsoft-fast-build/DESIGN.md index b2516fa7bdc..f1622d44bc1 100644 --- a/crates/microsoft-fast-build/DESIGN.md +++ b/crates/microsoft-fast-build/DESIGN.md @@ -48,10 +48,10 @@ render_template(template, state_str) | `content.rs` | `{{expr}}` and `{{{expr}}}` binding renderers, `html_escape` | | `attribute.rs` | Low-level HTML/attribute string parsing utilities + hydration attribute helpers; `strip_client_only_attrs` (shadow-DOM tags and nested element opening tags) | | `attribute_lookup.rs` | Static lookup tables mapping ARIA and HTML attribute names to their DOM property names | -| `config.rs` | `RenderConfig` struct and `AttributeNameStrategy` enum — rendering configuration options | +| `config.rs` | `RenderConfig` struct, `AttributeNameStrategy` enum, and `markers_v2` option — rendering configuration options | | `context.rs` | State value resolution: dot-path access, loop-variable scoping | | `expression.rs` | Boolean expression evaluator for `` | -| `hydration.rs` | `HydrationScope` — binding index tracking and data-free marker generation per template scope | +| `hydration.rs` | `HydrationScope` — binding index tracking and v3 data-free or opt-in v2 indexed marker generation per template scope | | `json.rs` | Hand-rolled JSON parser producing `JsonValue` | | `locator.rs` | `Locator` struct — maps element names to template strings; glob scanner; `` parser. Stored template bodies are first run through `escape_code_sample_elements` so `{`/`}` characters and the angle brackets of FAST directive tags (``, ``) inside `` elements are entity-escaped and therefore not interpreted as binding delimiters or directives. Also captures the inner `