From aeba9c2256a3bd2a0a0f9176970eead474dd4e42 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 5 Oct 2022 09:33:10 -0700 Subject: [PATCH 01/16] adding mutation observer and starting to refactor fast-element --- .../src/components/element-controller.ts | 15 +++++++++ .../fast-element/src/utilities.ts | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 1998a96f6cc..142bd37cc12 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -313,6 +313,21 @@ export class ElementController return; } + const boundObservables = this.boundObservables; + + // If we have any observables that were bound, re-apply their values. + if (boundObservables !== null) { + const propertyNames = Object.keys(boundObservables); + const element = this.source; + + for (let i = 0, ii = propertyNames.length; i < ii; ++i) { + const propertyName = propertyNames[i]; + (element as any)[propertyName] = boundObservables[propertyName]; + } + + this.boundObservables = null; + } + if (this.needsInitialization) { this.finishInitialization(); } else if (this.view !== null) { diff --git a/packages/web-components/fast-element/src/utilities.ts b/packages/web-components/fast-element/src/utilities.ts index 94b5aaaf4e0..f8cbca6b1b2 100644 --- a/packages/web-components/fast-element/src/utilities.ts +++ b/packages/web-components/fast-element/src/utilities.ts @@ -48,3 +48,35 @@ export function composedContains(reference: HTMLElement, test: HTMLElement): boo return false; } + +export class UnobservableMutationObserver extends MutationObserver { + private observedNodes: Set = new Set(); + + /** + * An extension of MutationObserver that supports unobserving nodes. + * @param callback - The callback to invoke when observed nodes are changed. + */ + constructor(private readonly callback: MutationCallback) { + function handler(mutations: MutationRecord[]) { + this.callback.call( + null, + mutations.filter(record => this.observedNodes.has(record.target)) + ); + } + + super(handler); + } + + public observe(target: Node, options?: MutationObserverInit | undefined): void { + this.observedNodes.add(target); + super.observe(target, options); + } + + public unobserve(target: Node): void { + this.observedNodes.delete(target); + + if (this.observedNodes.size < 1) { + this.disconnect(); + } + } +} From dab6ccc2f465fc86a398c26a59a68dcca528c27c Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Mon, 10 Oct 2022 13:24:18 -0700 Subject: [PATCH 02/16] break observable re-binding into it's own method --- .../src/components/element-controller.ts | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 142bd37cc12..00dce340004 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -205,7 +205,7 @@ export class ElementController * Adds the behavior to the component. * @param behavior - The behavior to add. */ - addBehavior(behavior: HostBehavior) { + public addBehavior(behavior: HostBehavior) { const targetBehaviors = this.behaviors ?? (this.behaviors = new Map()); const count = targetBehaviors.get(behavior) ?? 0; @@ -226,7 +226,7 @@ export class ElementController * @param behavior - The behavior to remove. * @param force - Forces removal even if this behavior was added more than once. */ - removeBehavior(behavior: HostBehavior, force: boolean = false) { + public removeBehavior(behavior: HostBehavior, force: boolean = false) { const targetBehaviors = this.behaviors; if (targetBehaviors === null) { return; @@ -313,20 +313,7 @@ export class ElementController return; } - const boundObservables = this.boundObservables; - - // If we have any observables that were bound, re-apply their values. - if (boundObservables !== null) { - const propertyNames = Object.keys(boundObservables); - const element = this.source; - - for (let i = 0, ii = propertyNames.length; i < ii; ++i) { - const propertyName = propertyNames[i]; - (element as any)[propertyName] = boundObservables[propertyName]; - } - - this.boundObservables = null; - } + this.bindObservables(); if (this.needsInitialization) { this.finishInitialization(); @@ -406,13 +393,16 @@ export class ElementController return false; } - private finishInitialization(): void { - const element = this.source; + /** + * bind any observable values that were set prior to element upgrade. + */ + protected bindObservables() { const boundObservables = this.boundObservables; // If we have any observables that were bound, re-apply their values. if (boundObservables !== null) { const propertyNames = Object.keys(boundObservables); + const element = this.source; for (let i = 0, ii = propertyNames.length; i < ii; ++i) { const propertyName = propertyNames[i]; @@ -421,7 +411,9 @@ export class ElementController this.boundObservables = null; } + } + private finishInitialization(): void { this.renderTemplate(this.template); this.addStyles(this.mainStyles); From a35b96484c7760fef73fb9a786944d8f0fd8a296 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Mon, 10 Oct 2022 15:23:04 -0700 Subject: [PATCH 03/16] re-apply observable values in constructor so that they don't need to be manually cached and re-applied during connection --- .../src/components/element-controller.ts | 38 +++---------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 00dce340004..4eecf24f4d8 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -30,7 +30,6 @@ function getShadowRoot(element: Element): ShadowRoot | null { export class ElementController extends PropertyChangeNotifier implements HostController { - private boundObservables: Record | null = null; private needsInitialization: boolean = true; private hasExistingShadowRoot = false; private _template: ElementViewTemplate | null = null; @@ -180,22 +179,19 @@ export class ElementController } } - // Capture any observable values that were set by the binding engine before - // the browser upgraded the element. Then delete the property since it will - // shadow the getter/setter that is required to make the observable operate. - // Later, in the connect callback, we'll re-apply the values. + // Delete and re-assign any observable properties that were assigned + // before the element is upgraded. This needs to happen because otherwise + // the value will shadow the observable getter and setter const accessors = Observable.getAccessors(element); if (accessors.length > 0) { - const boundObservables = (this.boundObservables = Object.create(null)); - for (let i = 0, ii = accessors.length; i < ii; ++i) { - const propertyName = accessors[i].name; + const propertyName = accessors[i].name as keyof TElement; const value = (element as any)[propertyName]; if (value !== void 0) { - delete (element as any)[propertyName]; - boundObservables[propertyName] = value; + delete element[propertyName]; + element[propertyName] = value; } } } @@ -313,8 +309,6 @@ export class ElementController return; } - this.bindObservables(); - if (this.needsInitialization) { this.finishInitialization(); } else if (this.view !== null) { @@ -393,26 +387,6 @@ export class ElementController return false; } - /** - * bind any observable values that were set prior to element upgrade. - */ - protected bindObservables() { - const boundObservables = this.boundObservables; - - // If we have any observables that were bound, re-apply their values. - if (boundObservables !== null) { - const propertyNames = Object.keys(boundObservables); - const element = this.source; - - for (let i = 0, ii = propertyNames.length; i < ii; ++i) { - const propertyName = propertyNames[i]; - (element as any)[propertyName] = boundObservables[propertyName]; - } - - this.boundObservables = null; - } - } - private finishInitialization(): void { this.renderTemplate(this.template); this.addStyles(this.mainStyles); From 65698ebfb62522c6be74a4682d4b2e17d36df91c Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Thu, 13 Oct 2022 12:31:48 -0700 Subject: [PATCH 04/16] revert unnecessary binding observer changes --- .../src/components/element-controller.ts | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 4eecf24f4d8..27449c00601 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -1,12 +1,13 @@ import { Message, Mutable, StyleStrategy, StyleTarget } from "../interfaces.js"; -import type { HostBehavior, HostController } from "../styles/host.js"; import { PropertyChangeNotifier } from "../observation/notifier.js"; import { Observable, SourceLifetime } from "../observation/observable.js"; import { FAST } from "../platform.js"; -import type { ElementViewTemplate } from "../templating/template.js"; -import type { ElementView } from "../templating/view.js"; import { ElementStyles } from "../styles/element-styles.js"; +import type { HostBehavior, HostController } from "../styles/host.js"; import type { ViewController } from "../templating/html-directive.js"; +import type { ElementViewTemplate } from "../templating/template.js"; +import type { ElementView } from "../templating/view.js"; +import { UnobservableMutationObserver } from "../utilities.js"; import { FASTElementDefinition } from "./fast-definitions.js"; const defaultEventOptions: CustomEventInit = { @@ -23,6 +24,15 @@ function getShadowRoot(element: Element): ShadowRoot | null { return element.shadowRoot ?? shadowRoots.get(element) ?? null; } +export interface ElementControllerStrategy { + create(element: HTMLElement, definition: FASTElementDefinition): ElementController; +} + +let elementControllerStrategy: ElementControllerStrategy = { + create(element, definition) { + return new ElementController(element, definition); + }, +}; /** * Controls the lifecycle and rendering of a `FASTElement`. * @public @@ -443,13 +453,50 @@ export class ElementController throw FAST.error(Message.missingElementDefinition); } - return ((element as any).$fastController = new ElementController( + return ((element as any).$fastController = elementControllerStrategy.create( element, definition )); } } +export class HydratableElementController< + TElement extends HTMLElement = HTMLElement +> extends ElementController { + private static hydrationObserver = new UnobservableMutationObserver( + HydratableElementController.hydrationObserverHandler + ); + private static hydrationObserverHandler(events: MutationRecord[]) { + for (const event of events) { + this.hydrationObserver.unobserve(event.target); + (event.target as any).$fastController.connect(); + } + } + + public connect() { + if (this.source.hasAttribute("defer-hydration")) { + HydratableElementController.hydrationObserver.observe(this.source, { + attributeFilter: ["defer-hydration"], + }); + } else { + super.connect(); + } + } + + public disconnect() { + super.disconnect(); + HydratableElementController.hydrationObserver.unobserve(this.source); + } +} + +export function addHydrationSupport() { + elementControllerStrategy = { + create(element, definition) { + return new HydratableElementController(element, definition); + }, + }; +} + /** * Converts a styleTarget into the operative target. When the provided target is an Element * that is a FASTElement, the function will return the ShadowRoot for that element. Otherwise, From 829d5301faafe0e5713ff4de9e8a3a6f4db74b9d Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Thu, 13 Oct 2022 14:21:55 -0700 Subject: [PATCH 05/16] refactor and reogranize draft implementation, adding tests --- .../src/components/element-controller.ts | 44 +---- .../src/components/hydration.spec.ts | 158 ++++++++++++++++++ .../fast-element/src/components/hydration.ts | 55 ++++++ 3 files changed, 221 insertions(+), 36 deletions(-) create mode 100644 packages/web-components/fast-element/src/components/hydration.spec.ts create mode 100644 packages/web-components/fast-element/src/components/hydration.ts diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 27449c00601..28228ced467 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -7,7 +7,6 @@ import type { HostBehavior, HostController } from "../styles/host.js"; import type { ViewController } from "../templating/html-directive.js"; import type { ElementViewTemplate } from "../templating/template.js"; import type { ElementView } from "../templating/view.js"; -import { UnobservableMutationObserver } from "../utilities.js"; import { FASTElementDefinition } from "./fast-definitions.js"; const defaultEventOptions: CustomEventInit = { @@ -33,6 +32,7 @@ let elementControllerStrategy: ElementControllerStrategy = { return new ElementController(element, definition); }, }; + /** * Controls the lifecycle and rendering of a `FASTElement`. * @public @@ -458,45 +458,17 @@ export class ElementController definition )); } -} -export class HydratableElementController< - TElement extends HTMLElement = HTMLElement -> extends ElementController { - private static hydrationObserver = new UnobservableMutationObserver( - HydratableElementController.hydrationObserverHandler - ); - private static hydrationObserverHandler(events: MutationRecord[]) { - for (const event of events) { - this.hydrationObserver.unobserve(event.target); - (event.target as any).$fastController.connect(); - } - } - - public connect() { - if (this.source.hasAttribute("defer-hydration")) { - HydratableElementController.hydrationObserver.observe(this.source, { - attributeFilter: ["defer-hydration"], - }); - } else { - super.connect(); - } - } - - public disconnect() { - super.disconnect(); - HydratableElementController.hydrationObserver.unobserve(this.source); + /** + * Sets the strategy that ElementController.forCustomElement uses to construct + * ElementController instances for an element. + * @param strategy - The strategy to use. + */ + public static setStrategy(strategy: ElementControllerStrategy) { + elementControllerStrategy = strategy; } } -export function addHydrationSupport() { - elementControllerStrategy = { - create(element, definition) { - return new HydratableElementController(element, definition); - }, - }; -} - /** * Converts a styleTarget into the operative target. When the provided target is an Element * that is a FASTElement, the function will return the ShadowRoot for that element. Otherwise, diff --git a/packages/web-components/fast-element/src/components/hydration.spec.ts b/packages/web-components/fast-element/src/components/hydration.spec.ts new file mode 100644 index 00000000000..60b1ec8269f --- /dev/null +++ b/packages/web-components/fast-element/src/components/hydration.spec.ts @@ -0,0 +1,158 @@ +import chai, { expect } from "chai"; +import { css, HostBehavior, Updates } from "../index.js"; +import { html } from "../templating/template.js"; +import { uniqueElementName } from "../testing/exports.js"; +import { ElementController } from "./element-controller.js"; +import { FASTElementDefinition, PartialFASTElementDefinition } from "./fast-definitions.js"; +import { FASTElement } from "./fast-element.js"; +import { HydratableElementController, addHydrationSupport } from "./hydration.js"; +import spies from "chai-spies"; + +chai.use(spies) + + +describe("The HydratableElementController", () => { + beforeEach(() => { + addHydrationSupport(); + }) + afterEach(() => { + ElementController.setStrategy({create(element, definition) { + return new ElementController(element, definition); + }}); + }) + function createController( + config: Omit = {}, + BaseClass = FASTElement + ) { + const name = uniqueElementName(); + const definition = FASTElementDefinition.compose( + class ControllerTest extends BaseClass { + static definition = { ...config, name }; + } + ).define(); + + const element = document.createElement(name) as FASTElement; + const controller = ElementController.forCustomElement(element); + + return { + name, + element, + controller, + definition, + shadowRoot: element.shadowRoot! as ShadowRoot & { + adoptedStyleSheets: CSSStyleSheet[]; + }, + }; + } + + it("A FASTElement's controller should be an instance of HydratableElementController after invoking 'addHydrationSupport'", () => { + const { element } = createController() + + expect(element.$fastController).to.be.instanceOf(HydratableElementController); + }); + + describe("without the `defer-hydration` attribute on connection", () => { + it("should render the element's template", async () => { + const { element } = createController({template: html`

Hello world

`}) + + document.body.appendChild(element); + await Updates.next(); + expect(element.shadowRoot?.innerHTML).to.be.equal("

Hello world

"); + document.body.removeChild(element) + }); + it("should apply the element's main stylesheet", async () => { + const { element } = createController({styles: css`:host{ color :red}`}) + + document.body.appendChild(element); + expect(element.$fastController.mainStyles?.isAttachedTo(element)).to.be.true; + document.body.removeChild(element) + }); + it("should invoke a HostBehavior's connectedCallback", async () => { + const behavior: HostBehavior = { + connectedCallback: chai.spy(() => {}) + } + + const { element, controller } = createController() + controller.addBehavior(behavior); + + document.body.appendChild(element); + expect(behavior.connectedCallback).to.have.been.called() + document.body.removeChild(element) + }); + }) + + describe("with the `defer-hydration` is set before connection", () => { + it("should not render the element's template", async () => { + const { element } = createController({template: html`

Hello world

`}) + + element.setAttribute('defer-hydration', ''); + document.body.appendChild(element); + await Updates.next(); + expect(element.shadowRoot?.innerHTML).be.equal(""); + document.body.removeChild(element) + }); + it("should not attach the element's main stylesheet", async () => { + const { element } = createController({styles: css`:host{ color :red}`}) + + element.setAttribute('defer-hydration', ''); + document.body.appendChild(element); + expect(element.$fastController.mainStyles?.isAttachedTo(element)).to.be.false; + document.body.removeChild(element) + }); + it("should not invoke a HostBehavior's connectedCallback", async () => { + const behavior: HostBehavior = { + connectedCallback: chai.spy(() => {}) + } + + const { element, controller } = createController() + element.setAttribute('defer-hydration', '') + controller.addBehavior(behavior); + + document.body.appendChild(element); + expect(behavior.connectedCallback).not.to.have.been.called() + document.body.removeChild(element) + }); + }) + + describe("when the `defer-hydration` attribute removed after connection", () => { + it("should render the element's template", async () => { + const { element } = createController({template: html`

Hello world

`}) + + element.setAttribute('defer-hydration', ''); + document.body.appendChild(element); + await Updates.next(); + expect(element.shadowRoot?.innerHTML).be.equal(""); + element.removeAttribute('defer-hydration') + await Updates.next(); + expect(element.shadowRoot?.innerHTML).to.be.equal("

Hello world

"); + document.body.removeChild(element) + }); + it("should attach the element's main stylesheet", async () => { + const { element } = createController({styles: css`:host{ color :red}`}) + + element.setAttribute('defer-hydration', ''); + document.body.appendChild(element); + expect(element.$fastController.mainStyles?.isAttachedTo(element)).to.be.false; + element.removeAttribute('defer-hydration'); + await Updates.next(); + expect(element.$fastController.mainStyles?.isAttachedTo(element)).to.be.true; + document.body.removeChild(element); + }); + it("should invoke a HostBehavior's connectedCallback", async () => { + const behavior: HostBehavior = { + connectedCallback: chai.spy(() => {}) + } + + const { element, controller } = createController() + element.setAttribute('defer-hydration', '') + controller.addBehavior(behavior); + + document.body.appendChild(element); + expect(behavior.connectedCallback).not.to.have.been.called(); + element.removeAttribute('defer-hydration'); + await Updates.next(); + expect(behavior.connectedCallback).to.have.been.called(); + document.body.removeChild(element) + }); + }) +}); diff --git a/packages/web-components/fast-element/src/components/hydration.ts b/packages/web-components/fast-element/src/components/hydration.ts new file mode 100644 index 00000000000..36d96ee874a --- /dev/null +++ b/packages/web-components/fast-element/src/components/hydration.ts @@ -0,0 +1,55 @@ +import { UnobservableMutationObserver } from "../utilities.js"; +import { ElementController, ElementControllerStrategy } from "./element-controller.js"; + +const deferHydrationAttribute = "defer-hydration"; + +/** + * @internal + */ +export class HydratableElementController< + TElement extends HTMLElement = HTMLElement +> extends ElementController { + private static hydrationObserver = new UnobservableMutationObserver( + HydratableElementController.hydrationObserverHandler + ); + + private static hydrationObserverHandler(records: MutationRecord[]) { + for (const record of records) { + HydratableElementController.hydrationObserver.unobserve(record.target); + (record.target as any).$fastController.connect(); + } + } + + public connect() { + if (this.source.hasAttribute(deferHydrationAttribute)) { + HydratableElementController.hydrationObserver.observe(this.source, { + attributeFilter: [deferHydrationAttribute], + }); + } else { + super.connect(); + } + } + + public disconnect() { + super.disconnect(); + HydratableElementController.hydrationObserver.unobserve(this.source); + } +} + +const hydratableElementControllerStrategy: ElementControllerStrategy = { + create(element, definition) { + return new HydratableElementController(element, definition); + }, +}; + +/** + * Configures FAST to support component hydration deferal. + * + * @beta + * @remarks + * This feature is designed to support SSR rendering, which is + * currently in beta. This feature is subject to change. + */ +export function addHydrationSupport() { + ElementController.setStrategy(hydratableElementControllerStrategy); +} From 45a64911e875b21afc6d053cefa7e9af03cef9b4 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Thu, 13 Oct 2022 14:31:39 -0700 Subject: [PATCH 06/16] added features to exports and updated api report --- .../web-components/fast-element/docs/api-report.md | 10 ++++++++++ packages/web-components/fast-element/src/index.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 6772979ae19..0ca1868a9b8 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -14,6 +14,9 @@ export interface Accessor { // @public export type AddBehavior = (behavior: HostBehavior) => void; +// @beta +export function addHydrationSupport(): void; + // @public export type AddViewBehaviorFactory = (factory: ViewBehaviorFactory) => string; @@ -258,12 +261,19 @@ export class ElementController exten onAttributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; removeBehavior(behavior: HostBehavior, force?: boolean): void; removeStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void; + static setStrategy(strategy: ElementControllerStrategy): void; readonly source: TElement; get template(): ElementViewTemplate | null; set template(value: ElementViewTemplate | null); readonly view: ElementView | null; } +// @public (undocumented) +export interface ElementControllerStrategy { + // (undocumented) + create(element: HTMLElement, definition: FASTElementDefinition): ElementController; +} + // @public export const elements: (selector?: string) => ElementsFilter; diff --git a/packages/web-components/fast-element/src/index.ts b/packages/web-components/fast-element/src/index.ts index 2dbf6ffde51..463d51efd3c 100644 --- a/packages/web-components/fast-element/src/index.ts +++ b/packages/web-components/fast-element/src/index.ts @@ -43,4 +43,8 @@ export * from "./templating/node-observation.js"; export * from "./components/fast-element.js"; export * from "./components/fast-definitions.js"; export * from "./components/attributes.js"; -export { ElementController } from "./components/element-controller.js"; +export { + ElementController, + ElementControllerStrategy, +} from "./components/element-controller.js"; +export { addHydrationSupport } from "./components/hydration.js"; From 436fdadeeb1f3268372647f869340404fde3541e Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Thu, 13 Oct 2022 14:32:19 -0700 Subject: [PATCH 07/16] Change files --- ...-fast-element-720b7480-23f3-4944-9238-cde64e7ca3c4.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@microsoft-fast-element-720b7480-23f3-4944-9238-cde64e7ca3c4.json diff --git a/change/@microsoft-fast-element-720b7480-23f3-4944-9238-cde64e7ca3c4.json b/change/@microsoft-fast-element-720b7480-23f3-4944-9238-cde64e7ca3c4.json new file mode 100644 index 00000000000..f4a8d37dc32 --- /dev/null +++ b/change/@microsoft-fast-element-720b7480-23f3-4944-9238-cde64e7ca3c4.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Added support to fast-element to support element hydration and the `defer-hydration` attribute", + "packageName": "@microsoft/fast-element", + "email": "nicholasrice@users.noreply.github.com", + "dependentChangeType": "prerelease" +} From 4ec2e986de0a5b3542cc64415d6495c237a25867 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Thu, 13 Oct 2022 14:45:28 -0700 Subject: [PATCH 08/16] align StyleStrategy and ElementControllerStrategy implementations --- .../src/components/element-controller.ts | 19 +++++++++++-------- .../src/components/hydration.spec.ts | 4 +--- .../fast-element/src/components/hydration.ts | 8 +------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 28228ced467..1f98131910e 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -23,16 +23,16 @@ function getShadowRoot(element: Element): ShadowRoot | null { return element.shadowRoot ?? shadowRoots.get(element) ?? null; } +let elementControllerStrategy: ElementControllerStrategy; + +/** + * A type that instantiates an ElementController + * @public + */ export interface ElementControllerStrategy { - create(element: HTMLElement, definition: FASTElementDefinition): ElementController; + new (element: HTMLElement, definition: FASTElementDefinition): ElementController; } -let elementControllerStrategy: ElementControllerStrategy = { - create(element, definition) { - return new ElementController(element, definition); - }, -}; - /** * Controls the lifecycle and rendering of a `FASTElement`. * @public @@ -452,8 +452,11 @@ export class ElementController if (definition === void 0) { throw FAST.error(Message.missingElementDefinition); } + if (elementControllerStrategy === undefined) { + elementControllerStrategy = ElementController; + } - return ((element as any).$fastController = elementControllerStrategy.create( + return ((element as any).$fastController = new elementControllerStrategy( element, definition )); diff --git a/packages/web-components/fast-element/src/components/hydration.spec.ts b/packages/web-components/fast-element/src/components/hydration.spec.ts index 60b1ec8269f..9dd14c0c8cc 100644 --- a/packages/web-components/fast-element/src/components/hydration.spec.ts +++ b/packages/web-components/fast-element/src/components/hydration.spec.ts @@ -16,9 +16,7 @@ describe("The HydratableElementController", () => { addHydrationSupport(); }) afterEach(() => { - ElementController.setStrategy({create(element, definition) { - return new ElementController(element, definition); - }}); + ElementController.setStrategy(ElementController); }) function createController( config: Omit = {}, diff --git a/packages/web-components/fast-element/src/components/hydration.ts b/packages/web-components/fast-element/src/components/hydration.ts index 36d96ee874a..9f80c4b586c 100644 --- a/packages/web-components/fast-element/src/components/hydration.ts +++ b/packages/web-components/fast-element/src/components/hydration.ts @@ -36,12 +36,6 @@ export class HydratableElementController< } } -const hydratableElementControllerStrategy: ElementControllerStrategy = { - create(element, definition) { - return new HydratableElementController(element, definition); - }, -}; - /** * Configures FAST to support component hydration deferal. * @@ -51,5 +45,5 @@ const hydratableElementControllerStrategy: ElementControllerStrategy = { * currently in beta. This feature is subject to change. */ export function addHydrationSupport() { - ElementController.setStrategy(hydratableElementControllerStrategy); + ElementController.setStrategy(HydratableElementController); } From 65ec2397abe586e86a484a6280afe65f631dc083 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Thu, 13 Oct 2022 14:49:57 -0700 Subject: [PATCH 09/16] fix api report --- packages/web-components/fast-element/docs/api-report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 0ca1868a9b8..8d4d7be4e7d 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -268,10 +268,10 @@ export class ElementController exten readonly view: ElementView | null; } -// @public (undocumented) +// @public export interface ElementControllerStrategy { // (undocumented) - create(element: HTMLElement, definition: FASTElementDefinition): ElementController; + new (element: HTMLElement, definition: FASTElementDefinition): ElementController; } // @public From 7ab89dc04d401c59cc646b219a0ac94135c0820d Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 19 Oct 2022 10:30:57 -0700 Subject: [PATCH 10/16] revert observable property re-assignment --- .../src/components/element-controller.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 1f98131910e..81a71162e95 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -40,6 +40,7 @@ export interface ElementControllerStrategy { export class ElementController extends PropertyChangeNotifier implements HostController { + private boundObservables: Record | null = null; private needsInitialization: boolean = true; private hasExistingShadowRoot = false; private _template: ElementViewTemplate | null = null; @@ -189,19 +190,21 @@ export class ElementController } } - // Delete and re-assign any observable properties that were assigned - // before the element is upgraded. This needs to happen because otherwise - // the value will shadow the observable getter and setter + // Capture any observable values that were set by the binding engine before + // the browser upgraded the element. Then delete the property since it will + // shadow the getter/setter that is required to make the observable operate. + // Later, in the connect callback, we'll re-apply the values. const accessors = Observable.getAccessors(element); if (accessors.length > 0) { + const boundObservables = (this.boundObservables = Object.create(null)); for (let i = 0, ii = accessors.length; i < ii; ++i) { const propertyName = accessors[i].name as keyof TElement; const value = (element as any)[propertyName]; if (value !== void 0) { delete element[propertyName]; - element[propertyName] = value; + boundObservables[propertyName] = value; } } } @@ -398,6 +401,21 @@ export class ElementController } private finishInitialization(): void { + const element = this.source; + const boundObservables = this.boundObservables; + + // If we have any observables that were bound, re-apply their values. + if (boundObservables !== null) { + const propertyNames = Object.keys(boundObservables); + + for (let i = 0, ii = propertyNames.length; i < ii; ++i) { + const propertyName = propertyNames[i]; + (element as any)[propertyName] = boundObservables[propertyName]; + } + + this.boundObservables = null; + } + this.renderTemplate(this.template); this.addStyles(this.mainStyles); From 2c021c10d8e378b42695e7c85704838a291a1f48 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 19 Oct 2022 10:55:52 -0700 Subject: [PATCH 11/16] re-organize hydration support into export paths --- .../fast-element/docs/api-report.md | 3 --- .../web-components/fast-element/package.json | 15 ++++++++----- .../src/components/hydration.spec.ts | 7 +++---- .../fast-element/src/components/hydration.ts | 21 +++++++------------ .../src/components/install-hydration.ts | 3 +++ .../web-components/fast-element/src/index.ts | 1 - 6 files changed, 24 insertions(+), 26 deletions(-) create mode 100644 packages/web-components/fast-element/src/components/install-hydration.ts diff --git a/packages/web-components/fast-element/docs/api-report.md b/packages/web-components/fast-element/docs/api-report.md index 7b5aae2259f..b70cb43f838 100644 --- a/packages/web-components/fast-element/docs/api-report.md +++ b/packages/web-components/fast-element/docs/api-report.md @@ -14,9 +14,6 @@ export interface Accessor { // @public export type AddBehavior = (behavior: HostBehavior) => void; -// @beta -export function addHydrationSupport(): void; - // @public export type AddViewBehaviorFactory = (factory: ViewBehaviorFactory) => string; diff --git a/packages/web-components/fast-element/package.json b/packages/web-components/fast-element/package.json index 01544f62ee9..e93c8034cec 100644 --- a/packages/web-components/fast-element/package.json +++ b/packages/web-components/fast-element/package.json @@ -50,10 +50,6 @@ "types": "./dist/dts/state/exports.d.ts", "default": "./dist/esm/state/exports.js" }, - "./context": { - "types": "./dist/dts/context.d.ts", - "default": "./dist/esm/context.js" - }, "./metadata": { "types": "./dist/dts/metadata.d.ts", "default": "./dist/esm/metadata.js" @@ -66,6 +62,14 @@ "types": "./dist/dts/di/di.d.ts", "default": "./dist/esm/di/di.js" }, + "./element-hydration": { + "types": "./dist/dts/components/hydration.d.ts", + "default": "./dist/esm/components/hydration.js" + }, + "./install-element-hydration": { + "types": "./dist/dts/components/install-hydration.d.ts", + "default": "./dist/esm/components/install-hydration.js" + }, "./pending-task": { "types": "./dist/dts/pending-task.d.ts", "default": "./dist/esm/pending-task.js" @@ -75,7 +79,8 @@ "unpkg": "dist/fast-element.min.js", "sideEffects": [ "./dist/esm/debug.js", - "./dist/esm/polyfills.js" + "./dist/esm/polyfills.js", + "./dist/esm/components/install-hydration.js" ], "scripts": { "clean:dist": "node ../../../build/clean.js dist", diff --git a/packages/web-components/fast-element/src/components/hydration.spec.ts b/packages/web-components/fast-element/src/components/hydration.spec.ts index 9dd14c0c8cc..3667795af7c 100644 --- a/packages/web-components/fast-element/src/components/hydration.spec.ts +++ b/packages/web-components/fast-element/src/components/hydration.spec.ts @@ -5,15 +5,14 @@ import { uniqueElementName } from "../testing/exports.js"; import { ElementController } from "./element-controller.js"; import { FASTElementDefinition, PartialFASTElementDefinition } from "./fast-definitions.js"; import { FASTElement } from "./fast-element.js"; -import { HydratableElementController, addHydrationSupport } from "./hydration.js"; +import { HydratableElementController } from "./hydration.js"; import spies from "chai-spies"; chai.use(spies) - describe("The HydratableElementController", () => { - beforeEach(() => { - addHydrationSupport(); + beforeEach(async () => { + ElementController.setStrategy(HydratableElementController); }) afterEach(() => { ElementController.setStrategy(ElementController); diff --git a/packages/web-components/fast-element/src/components/hydration.ts b/packages/web-components/fast-element/src/components/hydration.ts index 9f80c4b586c..91f32496f47 100644 --- a/packages/web-components/fast-element/src/components/hydration.ts +++ b/packages/web-components/fast-element/src/components/hydration.ts @@ -1,10 +1,13 @@ import { UnobservableMutationObserver } from "../utilities.js"; -import { ElementController, ElementControllerStrategy } from "./element-controller.js"; +import { ElementController } from "./element-controller.js"; const deferHydrationAttribute = "defer-hydration"; /** - * @internal + * An ElementController capable of hydrating FAST elements from + * Declarative Shadow DOM. + * + * @beta */ export class HydratableElementController< TElement extends HTMLElement = HTMLElement @@ -34,16 +37,8 @@ export class HydratableElementController< super.disconnect(); HydratableElementController.hydrationObserver.unobserve(this.source); } -} -/** - * Configures FAST to support component hydration deferal. - * - * @beta - * @remarks - * This feature is designed to support SSR rendering, which is - * currently in beta. This feature is subject to change. - */ -export function addHydrationSupport() { - ElementController.setStrategy(HydratableElementController); + public static install() { + ElementController.setStrategy(HydratableElementController); + } } diff --git a/packages/web-components/fast-element/src/components/install-hydration.ts b/packages/web-components/fast-element/src/components/install-hydration.ts new file mode 100644 index 00000000000..b8e329c6451 --- /dev/null +++ b/packages/web-components/fast-element/src/components/install-hydration.ts @@ -0,0 +1,3 @@ +import { HydratableElementController } from "./hydration.js"; + +HydratableElementController.install(); diff --git a/packages/web-components/fast-element/src/index.ts b/packages/web-components/fast-element/src/index.ts index 463d51efd3c..35965b29228 100644 --- a/packages/web-components/fast-element/src/index.ts +++ b/packages/web-components/fast-element/src/index.ts @@ -47,4 +47,3 @@ export { ElementController, ElementControllerStrategy, } from "./components/element-controller.js"; -export { addHydrationSupport } from "./components/hydration.js"; From ed25bb25e03084f4d63eb8a784d0548f54261eba Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 19 Oct 2022 10:57:41 -0700 Subject: [PATCH 12/16] leverage strategy API internally --- .../fast-element/src/components/element-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 81a71162e95..8012e7e5840 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -471,7 +471,7 @@ export class ElementController throw FAST.error(Message.missingElementDefinition); } if (elementControllerStrategy === undefined) { - elementControllerStrategy = ElementController; + ElementController.setStrategy(ElementController); } return ((element as any).$fastController = new elementControllerStrategy( From d0bb19e49f00bb6b3a35a534d4e966c342a95273 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 19 Oct 2022 10:59:24 -0700 Subject: [PATCH 13/16] make mutation observer internal for now --- packages/web-components/fast-element/src/utilities.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web-components/fast-element/src/utilities.ts b/packages/web-components/fast-element/src/utilities.ts index f8cbca6b1b2..64341e5961f 100644 --- a/packages/web-components/fast-element/src/utilities.ts +++ b/packages/web-components/fast-element/src/utilities.ts @@ -49,6 +49,9 @@ export function composedContains(reference: HTMLElement, test: HTMLElement): boo return false; } +/** + * @internal + */ export class UnobservableMutationObserver extends MutationObserver { private observedNodes: Set = new Set(); @@ -56,7 +59,7 @@ export class UnobservableMutationObserver extends MutationObserver { * An extension of MutationObserver that supports unobserving nodes. * @param callback - The callback to invoke when observed nodes are changed. */ - constructor(private readonly callback: MutationCallback) { + constructor(callback: MutationCallback) { function handler(mutations: MutationRecord[]) { this.callback.call( null, From 6401f786615945161711e13139ea071fa49448a7 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 19 Oct 2022 11:08:50 -0700 Subject: [PATCH 14/16] revert removal of context export path --- packages/web-components/fast-element/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/web-components/fast-element/package.json b/packages/web-components/fast-element/package.json index e93c8034cec..b4905de791a 100644 --- a/packages/web-components/fast-element/package.json +++ b/packages/web-components/fast-element/package.json @@ -50,6 +50,10 @@ "types": "./dist/dts/state/exports.d.ts", "default": "./dist/esm/state/exports.js" }, + "./context": { + "types": "./dist/dts/context.d.ts", + "default": "./dist/esm/context.js" + }, "./metadata": { "types": "./dist/dts/metadata.d.ts", "default": "./dist/esm/metadata.js" From aab870419d4180b52235a81e396376e751eb3f5e Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 19 Oct 2022 11:26:30 -0700 Subject: [PATCH 15/16] fix unit test failure --- .../fast-element/src/components/hydration.spec.ts | 4 ++-- packages/web-components/fast-element/src/utilities.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web-components/fast-element/src/components/hydration.spec.ts b/packages/web-components/fast-element/src/components/hydration.spec.ts index 3667795af7c..2dba199078b 100644 --- a/packages/web-components/fast-element/src/components/hydration.spec.ts +++ b/packages/web-components/fast-element/src/components/hydration.spec.ts @@ -11,8 +11,8 @@ import spies from "chai-spies"; chai.use(spies) describe("The HydratableElementController", () => { - beforeEach(async () => { - ElementController.setStrategy(HydratableElementController); + beforeEach(() => { + HydratableElementController.install(); }) afterEach(() => { ElementController.setStrategy(ElementController); diff --git a/packages/web-components/fast-element/src/utilities.ts b/packages/web-components/fast-element/src/utilities.ts index 64341e5961f..cc1d3e160c4 100644 --- a/packages/web-components/fast-element/src/utilities.ts +++ b/packages/web-components/fast-element/src/utilities.ts @@ -59,7 +59,7 @@ export class UnobservableMutationObserver extends MutationObserver { * An extension of MutationObserver that supports unobserving nodes. * @param callback - The callback to invoke when observed nodes are changed. */ - constructor(callback: MutationCallback) { + constructor(private readonly callback: MutationCallback) { function handler(mutations: MutationRecord[]) { this.callback.call( null, From ff7b2371ecb98733e19ab951be8ceb3b2a0883c2 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Wed, 19 Oct 2022 15:59:30 -0700 Subject: [PATCH 16/16] perform default strategy assignment as file side effect --- .../fast-element/src/components/element-controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 8012e7e5840..11f2f178406 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -470,9 +470,6 @@ export class ElementController if (definition === void 0) { throw FAST.error(Message.missingElementDefinition); } - if (elementControllerStrategy === undefined) { - ElementController.setStrategy(ElementController); - } return ((element as any).$fastController = new elementControllerStrategy( element, @@ -490,6 +487,9 @@ export class ElementController } } +// Set default strategy for ElementController +ElementController.setStrategy(ElementController); + /** * Converts a styleTarget into the operative target. When the provided target is an Element * that is a FASTElement, the function will return the ShadowRoot for that element. Otherwise,