Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
5 changes: 3 additions & 2 deletions packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>` which resolves to `true` when the element had a declarative shadow root (DSD) at connect time, regardless of whether hydration ran. Exposes `isHydrated: Promise<boolean>` which resolves to `true` only when hydration actually ran successfully. The `ViewController` interface also exposes both `isPrerendered` and `isHydrated` as `Promise<boolean>` 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.
Expand Down Expand Up @@ -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]
Expand Down
129 changes: 129 additions & 0 deletions packages/fast-element/src/components/element-controller.pw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> = [];
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<any>`<span>${x => x.lateValue}</span>`,
},
);

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("/");
Expand Down
9 changes: 7 additions & 2 deletions packages/fast-element/src/components/element-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,9 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
* customElements.define() completed.
*/
protected observeLateAttributes() {
if (getLateAttributeLookup(this.definition) === null) {
const lateAttributes = getLateAttributeLookup(this.definition);

if (lateAttributes === null) {
return;
}

Expand Down Expand Up @@ -664,7 +666,10 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
}
});

element[lateAttributeObserver].observe(element, { attributes: true });
element[lateAttributeObserver].observe(element, {
attributes: true,
attributeFilter: Object.keys(lateAttributes),
});
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/fast-element/test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down