Skip to content

Commit

Permalink
refactor and reogranize draft implementation, adding tests
Browse files Browse the repository at this point in the history
  • Loading branch information
nicholasrice committed Oct 13, 2022
1 parent 65698eb commit 829d530
Show file tree
Hide file tree
Showing 3 changed files with 221 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -33,6 +32,7 @@ let elementControllerStrategy: ElementControllerStrategy = {
return new ElementController(element, definition);
},
};

/**
* Controls the lifecycle and rendering of a `FASTElement`.
* @public
Expand Down Expand Up @@ -458,45 +458,17 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
definition
));
}
}

export class HydratableElementController<
TElement extends HTMLElement = HTMLElement
> extends ElementController<TElement> {
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,
Expand Down
158 changes: 158 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,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<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)
});
})
});
55 changes: 55 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,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<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);
}
}

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

0 comments on commit 829d530

Please sign in to comment.