From aac019b5eec227ecebd6147644c634ccddd6bbeb Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Fri, 29 May 2026 14:35:33 -0700 Subject: [PATCH] perf(fast-element): filter late attribute observation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-f878ae28-4c0e-4b08-a0c7-c21283f4b721.json | 7 + packages/fast-element/DESIGN.md | 5 +- .../components/element-controller.pw.spec.ts | 129 ++++++++++++++++++ .../src/components/element-controller.ts | 9 +- packages/fast-element/test/main.ts | 2 + 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 change/@microsoft-fast-element-f878ae28-4c0e-4b08-a0c7-c21283f4b721.json diff --git a/change/@microsoft-fast-element-f878ae28-4c0e-4b08-a0c7-c21283f4b721.json b/change/@microsoft-fast-element-f878ae28-4c0e-4b08-a0c7-c21283f4b721.json new file mode 100644 index 00000000000..7cd7062c191 --- /dev/null +++ b/change/@microsoft-fast-element-f878ae28-4c0e-4b08-a0c7-c21283f4b721.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Filter late attribute MutationObserver records.", + "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..700b4272460 100644 --- a/packages/fast-element/DESIGN.md +++ b/packages/fast-element/DESIGN.md @@ -90,7 +90,7 @@ The previous `FAST.getById()` slot registry, `FASTGlobal` type, and `KernelServi - Holds the element's `FASTElementDefinition` (name, template, styles, observed attributes). - Manages a `Stages` state machine: `disconnected → connecting → connected → disconnecting → disconnected`. - Exposes `isPrerendered: Promise` which resolves to `true` when the element had a declarative shadow root (DSD) at connect time, regardless of whether hydration ran. Exposes `isHydrated: Promise` which resolves to `true` only when hydration actually ran successfully. The `ViewController` interface also exposes both `isPrerendered` and `isHydrated` as `Promise` for custom directives. Attribute-skip logic during the hydration bind uses an internal `_skipAttrUpdates` flag that is never exposed as a public boolean. -- On `connect()`: restores pre-upgrade observable values, calls `connectedCallback` on all `HostBehavior`s, renders the current template into the shadow root when one is available, and applies styles. +- On `connect()`: restores pre-upgrade observable values, synchronizes any late-defined attribute-map attributes and observes only those late attributes for future DOM changes, calls `connectedCallback` on all `HostBehavior`s, renders the current template into the shadow root when one is available, and applies styles. - 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. @@ -400,7 +400,8 @@ flowchart TD PRERENDER -->|no| NORMAL[isPrerendered = false\nisHydrated = false] SETFLAG --> OBS[Restore pre-upgrade observable values] NORMAL --> OBS - OBS --> BEHAV[Connect HostBehaviors] + OBS --> LATEATTR[Sync and observe late-defined attributes] + LATEATTR --> BEHAV[Connect HostBehaviors] BEHAV --> RENDER{isPrerendered AND\ntemplate is hydratable?} RENDER -->|yes| HYDRATE[template.hydrate → HydrationView\nmaps existing DOM to binding targets\nisHydrated = true] RENDER -->|no| CLONE[ViewTemplate.render → HTMLView.appendTo shadow root] diff --git a/packages/fast-element/src/components/element-controller.pw.spec.ts b/packages/fast-element/src/components/element-controller.pw.spec.ts index 00860813353..83011798514 100644 --- a/packages/fast-element/src/components/element-controller.pw.spec.ts +++ b/packages/fast-element/src/components/element-controller.pw.spec.ts @@ -1743,6 +1743,135 @@ test.describe("The ElementController", () => { }); }); + test.describe("with late-defined attributes", () => { + test("syncs late attributes and filters late attribute observation", async ({ + page, + }) => { + await page.goto("/"); + + const result = await page.evaluate(async () => { + // @ts-expect-error: Client module. + const { + AttributeMap, + FASTElement, + FASTElementDefinition, + Schema, + html, + uniqueElementName, + } = await import("/main.js"); + + const NativeMutationObserver = window.MutationObserver; + const observeOptions: Array<{ + attributes?: boolean; + attributeFilter?: string[]; + }> = []; + const callbackAttributes: Array = []; + let callbackCount = 0; + + class FilteringMutationObserver extends NativeMutationObserver { + constructor(callback: MutationCallback) { + super((records, observer) => { + callbackCount++; + callbackAttributes.push( + ...records.map(record => record.attributeName), + ); + callback(records, observer); + }); + } + + observe(target: Node, options?: MutationObserverInit): void { + observeOptions.push({ + attributes: options?.attributes, + attributeFilter: options?.attributeFilter + ? [...options.attributeFilter] + : undefined, + }); + super.observe(target, options); + } + } + + (window as any).MutationObserver = FilteringMutationObserver; + + try { + class LateAttributeElement extends FASTElement {} + + const name = uniqueElementName(); + const definition = await FASTElementDefinition.compose( + LateAttributeElement, + { + name, + template: html`${x => x.lateValue}`, + }, + ); + + definition.define(); + + const schema = new Schema(name); + schema.addPath({ + rootPropertyName: "lateValue", + pathConfig: { + type: "default", + parentContext: null, + currentContext: null, + path: "lateValue", + }, + childrenMap: null, + }); + new AttributeMap( + LateAttributeElement.prototype, + schema, + definition, + ).defineProperties(); + + const element = document.createElement(name) as any; + element.setAttribute("late-value", "initial"); + document.body.appendChild(element); + await new Promise(resolve => requestAnimationFrame(resolve)); + + const initialProperty = element.lateValue; + const initialText = element.shadowRoot?.textContent ?? ""; + + element.setAttribute("unrelated", "noise"); + await new Promise(resolve => setTimeout(resolve, 0)); + + const callbacksAfterUnrelated = callbackCount; + const attributesAfterUnrelated = [...callbackAttributes]; + + element.setAttribute("late-value", "updated"); + await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => requestAnimationFrame(resolve)); + + return { + attributesAfterUnrelated, + callbackAttributes, + callbacksAfterLate: callbackCount, + callbacksAfterUnrelated, + initialProperty, + initialText, + observeOptions, + updatedProperty: element.lateValue, + updatedText: element.shadowRoot?.textContent ?? "", + }; + } finally { + (window as any).MutationObserver = NativeMutationObserver; + } + }); + + expect(result.initialProperty).toBe("initial"); + expect(result.initialText).toContain("initial"); + expect(result.observeOptions).toContainEqual({ + attributes: true, + attributeFilter: ["late-value"], + }); + expect(result.callbacksAfterUnrelated).toBe(0); + expect(result.attributesAfterUnrelated).toEqual([]); + expect(result.callbacksAfterLate).toBe(1); + expect(result.callbackAttributes).toEqual(["late-value"]); + expect(result.updatedProperty).toBe("updated"); + expect(result.updatedText).toContain("updated"); + }); + }); + test.describe("with pre-existing shadow dom on the host", () => { test("re-renders the view during connect", async ({ page }) => { await page.goto("/"); diff --git a/packages/fast-element/src/components/element-controller.ts b/packages/fast-element/src/components/element-controller.ts index 1885ae24098..5fe3ef551b0 100644 --- a/packages/fast-element/src/components/element-controller.ts +++ b/packages/fast-element/src/components/element-controller.ts @@ -629,7 +629,9 @@ export class ElementController * customElements.define() completed. */ protected observeLateAttributes() { - if (getLateAttributeLookup(this.definition) === null) { + const lateAttributes = getLateAttributeLookup(this.definition); + + if (lateAttributes === null) { return; } @@ -664,7 +666,10 @@ export class ElementController } }); - element[lateAttributeObserver].observe(element, { attributes: true }); + element[lateAttributeObserver].observe(element, { + attributes: true, + attributeFilter: Object.keys(lateAttributes), + }); } /** diff --git a/packages/fast-element/test/main.ts b/packages/fast-element/test/main.ts index fbb41ce6cd6..8fb2c310300 100644 --- a/packages/fast-element/test/main.ts +++ b/packages/fast-element/test/main.ts @@ -73,6 +73,8 @@ export { oneTime } from "../src/binding/one-time.js"; export { listener, oneWay } from "../src/binding/one-way.js"; export { Signal, signal } from "../src/binding/signal.js"; export { twoWay } from "../src/binding/two-way.js"; +export { Schema } from "../src/components/schema.js"; +export { AttributeMap } from "../src/declarative/attribute-map.js"; export { isString } from "../src/interfaces.js"; export { Metadata } from "../src/metadata.js"; export { ArrayObserver, lengthOf, Splice } from "../src/observation/arrays.js";