Skip to content

Commit

Permalink
Merge ff7b237 into a70a376
Browse files Browse the repository at this point in the history
  • Loading branch information
nicholasrice authored Oct 19, 2022
2 parents a70a376 + ff7b237 commit 41ee4e7
Show file tree
Hide file tree
Showing 9 changed files with 295 additions and 11 deletions.
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;
}
}

// 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();
}
}
}

0 comments on commit 41ee4e7

Please sign in to comment.