Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add defer hydration support to fast-element #6441

Merged
merged 18 commits into from
Oct 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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": "Added support to fast-element to support element hydration and the `defer-hydration` attribute",
"packageName": "@microsoft/fast-element",
"email": "[email protected]",
"dependentChangeType": "prerelease"
}
7 changes: 7 additions & 0 deletions packages/web-components/fast-element/docs/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,19 @@ export class ElementController<TElement extends HTMLElement = HTMLElement> exten
onAttributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
removeBehavior(behavior: HostBehavior<TElement>, force?: boolean): void;
removeStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void;
static setStrategy(strategy: ElementControllerStrategy): void;
readonly source: TElement;
get template(): ElementViewTemplate<TElement> | null;
set template(value: ElementViewTemplate<TElement> | null);
readonly view: ElementView<TElement> | null;
}

// @public
export interface ElementControllerStrategy {
// (undocumented)
new (element: HTMLElement, definition: FASTElementDefinition): ElementController;
}

// @public
export const elements: (selector?: string) => ElementsFilter;

Expand Down
11 changes: 10 additions & 1 deletion packages/web-components/fast-element/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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"
Expand All @@ -75,7 +83,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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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 { FASTElementDefinition } from "./fast-definitions.js";

const defaultEventOptions: CustomEventInit = {
Expand All @@ -23,6 +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 {
new (element: HTMLElement, definition: FASTElementDefinition): ElementController;
}

/**
* Controls the lifecycle and rendering of a `FASTElement`.
* @public
Expand Down Expand Up @@ -188,13 +198,12 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>

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];
delete element[propertyName];
boundObservables[propertyName] = value;
}
}
Expand All @@ -205,7 +214,7 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
* Adds the behavior to the component.
* @param behavior - The behavior to add.
*/
addBehavior(behavior: HostBehavior<TElement>) {
public addBehavior(behavior: HostBehavior<TElement>) {
const targetBehaviors = this.behaviors ?? (this.behaviors = new Map());
const count = targetBehaviors.get(behavior) ?? 0;

Expand All @@ -226,7 +235,7 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
* @param behavior - The behavior to remove.
* @param force - Forces removal even if this behavior was added more than once.
*/
removeBehavior(behavior: HostBehavior<TElement>, force: boolean = false) {
public removeBehavior(behavior: HostBehavior<TElement>, force: boolean = false) {
const targetBehaviors = this.behaviors;
if (targetBehaviors === null) {
return;
Expand Down Expand Up @@ -462,13 +471,25 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
throw FAST.error(Message.missingElementDefinition);
}

return ((element as any).$fastController = new ElementController(
return ((element as any).$fastController = new elementControllerStrategy(
element,
definition
));
}

/**
* 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;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set strategy right here?


// 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,
Expand Down
155 changes: 155 additions & 0 deletions packages/web-components/fast-element/src/components/hydration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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 } from "./hydration.js";
import spies from "chai-spies";

chai.use(spies)

describe("The HydratableElementController", () => {
beforeEach(() => {
HydratableElementController.install();
})
afterEach(() => {
ElementController.setStrategy(ElementController);
})
function createController(
config: Omit<PartialFASTElementDefinition, "name"> = {},
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`<p>Hello world</p>`})

document.body.appendChild(element);
await Updates.next();
expect(element.shadowRoot?.innerHTML).to.be.equal("<p>Hello world</p>");
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`<p>Hello world</p>`})

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`<p>Hello world</p>`})

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("<p>Hello world</p>");
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)
});
})
});
44 changes: 44 additions & 0 deletions packages/web-components/fast-element/src/components/hydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { UnobservableMutationObserver } from "../utilities.js";
import { ElementController } from "./element-controller.js";

const deferHydrationAttribute = "defer-hydration";

/**
* An ElementController capable of hydrating FAST elements from
* Declarative Shadow DOM.
*
* @beta
*/
export class HydratableElementController<
TElement extends HTMLElement = HTMLElement
> extends ElementController<TElement> {
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);
}

public static install() {
ElementController.setStrategy(HydratableElementController);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { HydratableElementController } from "./hydration.js";

HydratableElementController.install();
5 changes: 4 additions & 1 deletion packages/web-components/fast-element/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ 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";
35 changes: 35 additions & 0 deletions packages/web-components/fast-element/src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,38 @@ export function composedContains(reference: HTMLElement, test: HTMLElement): boo

return false;
}

/**
* @internal
*/
export class UnobservableMutationObserver extends MutationObserver {
private observedNodes: Set<Node> = 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();
}
}
}