diff --git a/change/@microsoft-fast-element-66c55455-9b64-4d8c-a517-be10925cfd1a.json b/change/@microsoft-fast-element-66c55455-9b64-4d8c-a517-be10925cfd1a.json new file mode 100644 index 00000000000..0a3001b905f --- /dev/null +++ b/change/@microsoft-fast-element-66c55455-9b64-4d8c-a517-be10925cfd1a.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Adds support for FASTElement hydration", + "packageName": "@microsoft/fast-element", + "email": "171390049+prabhujayapal@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@microsoft-fast-foundation-7a94883b-0ee2-4e65-918c-91a2ce70ebc6.json b/change/@microsoft-fast-foundation-7a94883b-0ee2-4e65-918c-91a2ce70ebc6.json new file mode 100644 index 00000000000..670c3fcc0da --- /dev/null +++ b/change/@microsoft-fast-foundation-7a94883b-0ee2-4e65-918c-91a2ce70ebc6.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "update FAST DOM shim for Playwright tests", + "packageName": "@microsoft/fast-foundation", + "email": "171390049+prabhujayapal@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/change/@microsoft-fast-ssr-f22b45fd-23fb-4386-82fa-72d59f744cd6.json b/change/@microsoft-fast-ssr-f22b45fd-23fb-4386-82fa-72d59f744cd6.json new file mode 100644 index 00000000000..20c505c1cfa --- /dev/null +++ b/change/@microsoft-fast-ssr-f22b45fd-23fb-4386-82fa-72d59f744cd6.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Adds support for FASTElement hydration", + "packageName": "@microsoft/fast-ssr", + "email": "171390049+prabhujayapal@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/fast-element/docs/api-report.api.md b/packages/web-components/fast-element/docs/api-report.api.md index bb3a03f26b5..cbf3f2e48c8 100644 --- a/packages/web-components/fast-element/docs/api-report.api.md +++ b/packages/web-components/fast-element/docs/api-report.api.md @@ -279,25 +279,41 @@ export class ElementController exten constructor(element: TElement, definition: FASTElementDefinition); addBehavior(behavior: HostBehavior): void; addStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void; + // (undocumented) + protected behaviors: Map, number> | null; + // (undocumented) + protected bindObservables(): void; connect(): void; + // (undocumented) + protected connectBehaviors(): void; get context(): ExecutionContext; readonly definition: FASTElementDefinition; disconnect(): void; + // (undocumented) + protected disconnectBehaviors(): void; emit(type: string, detail?: any, options?: Omit): void | boolean; static forCustomElement(element: HTMLElement): ElementController; get isBound(): boolean; get isConnected(): boolean; get mainStyles(): ElementStyles | null; set mainStyles(value: ElementStyles | null); + // (undocumented) + protected needsInitialization: boolean; onAttributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; onUnbind(behavior: { unbind(controller: ExpressionController): any; }): void; removeBehavior(behavior: HostBehavior, force?: boolean): void; removeStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void; + // (undocumented) + protected renderTemplate(template: ElementViewTemplate | null | undefined): void; static setStrategy(strategy: ElementControllerStrategy): void; readonly source: TElement; get sourceLifetime(): SourceLifetime | undefined; + // Warning: (ae-forgotten-export) The symbol "Stages" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected stage: Stages; get template(): ElementViewTemplate | null; set template(value: ElementViewTemplate | null); readonly view: ElementView | null; @@ -523,6 +539,8 @@ export interface HTMLDirectiveDefinition { createView(hostBindingTarget?: Element): HTMLView; + // (undocumented) + readonly factories: CompiledViewBehaviorFactory[]; } // @public @@ -530,34 +548,24 @@ export type HTMLTemplateTag = ((strings: TemplateS partial(html: string): InlineTemplateDirective; }; +// Warning: (ae-forgotten-export) The symbol "DefaultExecutionContext" needs to be exported by the entry point index.d.ts +// // @public -export class HTMLView implements ElementView, SyntheticView, ExecutionContext { +export class HTMLView extends DefaultExecutionContext implements ElementView, SyntheticView, ExecutionContext { constructor(fragment: DocumentFragment, factories: ReadonlyArray, targets: ViewBehaviorTargets); appendTo(node: Node): void; bind(source: TSource, context?: ExecutionContext): void; context: ExecutionContext; dispose(): void; static disposeContiguousBatch(views: SyntheticView[]): void; - get event(): Event; - eventDetail(): TDetail; - eventTarget(): TTarget; firstChild: Node; - index: number; insertBefore(node: Node): void; isBound: boolean; - get isEven(): boolean; - get isFirst(): boolean; - get isInMiddle(): boolean; - get isLast(): boolean; - get isOdd(): boolean; lastChild: Node; - length: number; // (undocumented) onUnbind(behavior: { - unbind(controller: ViewController): any; + unbind(controller: ViewController): void; }): void; - readonly parent: TParent; - readonly parentContext: ExecutionContext; remove(): void; source: TSource | null; readonly sourceLifetime: SourceLifetime; @@ -566,6 +574,43 @@ export class HTMLView implements ElementView extends ElementController { + // (undocumented) + connect(): void; + // (undocumented) + disconnect(): void; + // (undocumented) + static install(): void; + protected needsHydration?: boolean; +} + +// @public (undocumented) +export interface HydratableView extends ElementView, SyntheticView, DefaultExecutionContext { + // (undocumented) + [Hydratable]: symbol; + // Warning: (ae-forgotten-export) The symbol "ViewNodes" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly bindingViewBoundaries: Record; + // Warning: (ae-forgotten-export) The symbol "HydrationStage" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly hydrationStage: keyof typeof HydrationStage; +} + +// @public (undocumented) +export class HydrationBindingError extends Error { + constructor( + message: string | undefined, + factory: ViewBehaviorFactory, + fragment: DocumentFragment, + templateString: string); + readonly factory: ViewBehaviorFactory; + readonly fragment: DocumentFragment; + readonly templateString: string; +} + // @public export class InlineTemplateDirective implements HTMLDirective { constructor(html: string, factories?: Record); @@ -696,6 +741,32 @@ export class RefDirective extends StatelessAttachedAttributeDirective { targetNodeId: string; } +// @public +export function render(value?: Expression | Binding | {}, template?: ContentTemplate | string | Expression | Binding): CaptureType; + +// @public +export class RenderBehavior implements ViewBehavior, Subscriber { + constructor(directive: RenderDirective); + bind(controller: ViewController): void; + // @internal (undocumented) + handleChange(source: any, observer: ExpressionObserver): void; + unbind(controller: ViewController): void; +} + +// @public +export class RenderDirective implements HTMLDirective, ViewBehaviorFactory, BindingDirective { + constructor(dataBinding: Binding, templateBinding: Binding, templateBindingDependsOnData: boolean); + createBehavior(): RenderBehavior; + createHTML(add: AddViewBehaviorFactory): string; + // (undocumented) + readonly dataBinding: Binding; + targetNodeId: string; + // (undocumented) + readonly templateBinding: Binding; + // (undocumented) + readonly templateBindingDependsOnData: boolean; +} + // @public export function repeat = ReadonlyArray, TParent = any>(items: Expression | Binding | ReadonlyArray, template: Expression> | Binding> | ViewTemplate, options?: RepeatOptions): CaptureType; @@ -922,6 +993,8 @@ export interface ViewController extends Expression // @public export class ViewTemplate implements ElementViewTemplate, SyntheticViewTemplate { constructor(html: string | HTMLTemplateElement, factories?: Record, policy?: DOMPolicy | undefined); + // @internal (undocumented) + compile(): HTMLTemplateCompilationResult; create(hostBindingTarget?: Element): HTMLView; static create(strings: string[], values: TemplateValue[], policy?: DOMPolicy): ViewTemplate; readonly factories: Record; diff --git a/packages/web-components/fast-element/src/components/element-controller.spec.ts b/packages/web-components/fast-element/src/components/element-controller.spec.ts index b038341bf84..c81e45e6611 100644 --- a/packages/web-components/fast-element/src/components/element-controller.spec.ts +++ b/packages/web-components/fast-element/src/components/element-controller.spec.ts @@ -21,7 +21,7 @@ describe("The ElementController", () => { const cssB = "class-b { color: blue; }"; const stylesB = css`${cssB}`; - function createController( + function createController( config: Omit = {}, BaseClass = FASTElement ) { @@ -33,7 +33,7 @@ describe("The ElementController", () => { ).define(); const element = document.createElement(name); - const controller = ElementController.forCustomElement(element); + const controller = ElementController.forCustomElement(element) as T; return { name, @@ -548,6 +548,41 @@ describe("The ElementController", () => { controller.disconnect(); expect(behavior.disconnectedCallback).to.have.been.called(); }); + + it("should not connect behaviors more than once without first disconnecting the behavior", () => { + class TestController extends ElementController { + public connectBehaviors() { + super.connectBehaviors(); + } + + public disconnectBehaviors() { + super.disconnectBehaviors(); + } + } + + ElementController.setStrategy(TestController); + const behavior: HostBehavior = { + connectedCallback: chai.spy(), + disconnectedCallback: chai.spy() + }; + const { controller } = createController({styles: css``.withBehaviors(behavior)}); + controller.connect(); + controller.connectBehaviors(); + + expect(behavior.connectedCallback).to.have.been.called.once; + + controller.disconnect(); + controller.disconnectBehaviors(); + expect(behavior.disconnectedCallback).to.have.been.called.once; + + controller.connect(); + controller.connectBehaviors(); + + expect(behavior.connectedCallback).to.have.been.called.twice; + + ElementController.setStrategy(ElementController); + }); + it("should add behaviors added by a stylesheet when added and remove them the stylesheet is removed", () => { const behavior: HostBehavior = { addedCallback: chai.spy(), 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 f4585db5089..276403616d1 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -13,7 +13,9 @@ import type { StyleStrategy, StyleTarget } from "../styles/style-strategy.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"; +import { HydrationMarkup, isHydratable } from "./hydration.js"; const defaultEventOptions: CustomEventInit = { bubbles: true, @@ -55,17 +57,22 @@ export class ElementController implements HostController { private boundObservables: Record | null = null; - private needsInitialization: boolean = true; + protected needsInitialization: boolean = true; private hasExistingShadowRoot = false; private _template: ElementViewTemplate | null = null; - private stage: Stages = Stages.disconnected; + protected stage: Stages = Stages.disconnected; /** * A guard against connecting behaviors multiple times * during connect in scenarios where a behavior adds * another behavior during it's connectedCallback */ private guardBehaviorConnection = false; - private behaviors: Map, number> | null = null; + protected behaviors: Map, number> | null = null; + /** + * Tracks whether behaviors are connected so that + * behaviors cant be connected multiple times + */ + private behaviorsConnected: boolean = false; private _mainStyles: ElementStyles | null = null; /** @@ -372,7 +379,23 @@ export class ElementController this.stage = Stages.connecting; - // If we have any observables that were bound, re-apply their values. + this.bindObservables(); + this.connectBehaviors(); + + if (this.needsInitialization) { + this.renderTemplate(this.template); + this.addStyles(this.mainStyles); + + this.needsInitialization = false; + } else if (this.view !== null) { + this.view.bind(this.source); + } + + this.stage = Stages.connected; + Observable.notify(this, isConnectedPropertyName); + } + + protected bindObservables() { if (this.boundObservables !== null) { const element = this.source; const boundObservables = this.boundObservables; @@ -385,28 +408,36 @@ export class ElementController this.boundObservables = null; } + } - const behaviors = this.behaviors; - if (behaviors !== null) { - this.guardBehaviorConnection = true; - for (const key of behaviors.keys()) { - key.connectedCallback && key.connectedCallback(this); + protected connectBehaviors() { + if (this.behaviorsConnected === false) { + const behaviors = this.behaviors; + if (behaviors !== null) { + this.guardBehaviorConnection = true; + for (const key of behaviors.keys()) { + key.connectedCallback && key.connectedCallback(this); + } + + this.guardBehaviorConnection = false; } - this.guardBehaviorConnection = false; + this.behaviorsConnected = true; } + } - if (this.needsInitialization) { - this.renderTemplate(this.template); - this.addStyles(this.mainStyles); + protected disconnectBehaviors() { + if (this.behaviorsConnected === true) { + const behaviors = this.behaviors; - this.needsInitialization = false; - } else if (this.view !== null) { - this.view.bind(this.source); - } + if (behaviors !== null) { + for (const key of behaviors.keys()) { + key.disconnectedCallback && key.disconnectedCallback(this); + } + } - this.stage = Stages.connected; - Observable.notify(this, isConnectedPropertyName); + this.behaviorsConnected = false; + } } /** @@ -424,12 +455,7 @@ export class ElementController this.view.unbind(); } - const behaviors = this.behaviors; - if (behaviors !== null) { - for (const key of behaviors.keys()) { - key.disconnectedCallback && key.disconnectedCallback(this); - } - } + this.disconnectBehaviors(); this.stage = Stages.disconnected; } @@ -474,7 +500,7 @@ export class ElementController return false; } - private renderTemplate(template: ElementViewTemplate | null | undefined): void { + protected renderTemplate(template: ElementViewTemplate | null | undefined): void { // When getting the host to render to, we start by looking // up the shadow root. If there isn't one, then that means // we're doing a Light DOM render to the element's direct children. @@ -685,3 +711,117 @@ if (ElementStyles.supportsAdoptedStyleSheets) { } else { ElementStyles.setDefaultStrategy(StyleElementStrategy); } + +const deferHydrationAttribute = "defer-hydration"; +const needsHydrationAttribute = "needs-hydration"; + +/** + * An ElementController capable of hydrating FAST elements from + * Declarative Shadow DOM. + * + * @beta + */ +export class HydratableElementController< + TElement extends HTMLElement = HTMLElement +> extends ElementController { + /** + * Controls whether the controller will hydrate during the connect() method. + * Initialized during the first connect() call to true when the `needs-hydration` + * attribute is present on the element. + */ + protected needsHydration?: boolean; + 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() { + // Initialize needsHydration on first connect + if (this.needsHydration === undefined) { + this.needsHydration = + this.source.getAttribute(needsHydrationAttribute) !== null; + } + + // If the `defer-hydration` attribute exists on the source, + // wait for it to be removed before continuing connection behavior. + if (this.source.hasAttribute(deferHydrationAttribute)) { + HydratableElementController.hydrationObserver.observe(this.source, { + attributeFilter: [deferHydrationAttribute], + }); + + return; + } + + // If the controller does not need to be hydrated, defer connection behavior + // to the base-class. This case handles element re-connection and initial connection + // of elements that did not get declarative shadow-dom emitted, as well as if an extending + // class + if (!this.needsHydration) { + super.connect(); + return; + } + + if (this.stage !== Stages.disconnected) { + return; + } + + this.stage = Stages.connecting; + + this.bindObservables(); + this.connectBehaviors(); + + const element = this.source; + const host = getShadowRoot(element) ?? element; + + if (this.template) { + if (isHydratable(this.template)) { + let firstChild = host.firstChild!; + let lastChild = host.lastChild!; + + if (element.shadowRoot === null) { + // handle element boundary markers when shadowRoot is not present + if (HydrationMarkup.isElementBoundaryStartMarker(firstChild)) { + (firstChild as Comment).data = ""; + firstChild = firstChild.nextSibling!; + } + + if (HydrationMarkup.isElementBoundaryEndMarker(lastChild!)) { + (lastChild as Comment).data = ""; + lastChild = lastChild.previousSibling!; + } + } + + (this as Mutable).view = this.template.hydrate( + firstChild, + lastChild, + element + ); + this.view?.bind(this.source); + } else { + this.renderTemplate(this.template); + } + } + + this.addStyles(this.mainStyles); + + this.stage = Stages.connected; + this.source.removeAttribute(needsHydrationAttribute); + this.needsInitialization = this.needsHydration = false; + Observable.notify(this, isConnectedPropertyName); + } + + public disconnect() { + super.disconnect(); + HydratableElementController.hydrationObserver.unobserve(this.source); + } + + public static install() { + ElementController.setStrategy(HydratableElementController); + } +} diff --git a/packages/web-components/fast-element/src/components/element-hydration.ts b/packages/web-components/fast-element/src/components/element-hydration.ts new file mode 100644 index 00000000000..a5c31c683dd --- /dev/null +++ b/packages/web-components/fast-element/src/components/element-hydration.ts @@ -0,0 +1,2 @@ +export { HydratableElementController } from "./element-controller.js"; +export * from "./hydration.js"; 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 2dba199078b..9292e8d3d4e 100644 --- a/packages/web-components/fast-element/src/components/hydration.spec.ts +++ b/packages/web-components/fast-element/src/components/hydration.spec.ts @@ -2,11 +2,11 @@ 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 { ElementController, HydratableElementController } 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"; +import { HydrationMarkup } from "./hydration.js"; chai.use(spies) @@ -17,9 +17,9 @@ describe("The HydratableElementController", () => { afterEach(() => { ElementController.setStrategy(ElementController); }) - function createController( + function createController( config: Omit = {}, - BaseClass = FASTElement + BaseClass = FASTElement, ) { const name = uniqueElementName(); const definition = FASTElementDefinition.compose( @@ -29,7 +29,8 @@ describe("The HydratableElementController", () => { ).define(); const element = document.createElement(name) as FASTElement; - const controller = ElementController.forCustomElement(element); + element.setAttribute("needs-hydration", ""); + const controller = ElementController.forCustomElement(element) as T; return { name, @@ -48,6 +49,14 @@ describe("The HydratableElementController", () => { expect(element.$fastController).to.be.instanceOf(HydratableElementController); }); + it("should remove the needs-hydration attribute after connection", () => { + const { controller, element } = createController(); + + expect(element.hasAttribute("needs-hydration")).to.equal(true); + controller.connect(); + expect(element.hasAttribute("needs-hydration")).to.equal(false); + }); + describe("without the `defer-hydration` attribute on connection", () => { it("should render the element's template", async () => { const { element } = createController({template: html`

Hello world

`}) @@ -76,7 +85,7 @@ describe("The HydratableElementController", () => { 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 () => { @@ -109,7 +118,24 @@ describe("The HydratableElementController", () => { expect(behavior.connectedCallback).not.to.have.been.called() document.body.removeChild(element) }); - }) + + it("should defer connection when 'needsHydration' is assigned false and 'defer-hydration' attribute exists", async () => { + class Controller extends HydratableElementController { + needsHydration = false; + } + + ElementController.setStrategy(Controller) + const { element, controller } = createController({template: html`

Hello world

`}) + element.setAttribute('defer-hydration', '') + controller.connect(); + await Updates.next(); + expect(controller.isConnected).to.equal(false); + element.removeAttribute('defer-hydration'); + await Updates.next(); + expect(controller.isConnected).to.equal(true); + ElementController.setStrategy(HydratableElementController) + }) + }); describe("when the `defer-hydration` attribute removed after connection", () => { it("should render the element's template", async () => { @@ -151,5 +177,74 @@ describe("The HydratableElementController", () => { expect(behavior.connectedCallback).to.have.been.called(); document.body.removeChild(element) }); - }) + }); }); + +describe("HydrationMarkup", () => { + describe("content bindings", () => { + it("isContentBindingStartMarker should return true when provided the output of isBindingStartMarker", () => { + expect(HydrationMarkup.isContentBindingStartMarker(HydrationMarkup.contentBindingStartMarker(12, "foobar"))).to.equal(true); + }); + it("isContentBindingStartMarker should return false when provided the output of isBindingEndMarker", () => { + expect(HydrationMarkup.isContentBindingStartMarker(HydrationMarkup.contentBindingEndMarker(12, "foobar"))).to.equal(false); + }); + it("isContentBindingEndMarker should return true when provided the output of isBindingEndMarker", () => { + expect(HydrationMarkup.isContentBindingEndMarker(HydrationMarkup.contentBindingEndMarker(12, "foobar"))).to.equal(true); + }); + it("isContentBindingStartMarker should return false when provided the output of isBindingEndMarker", () => { + expect(HydrationMarkup.isContentBindingStartMarker(HydrationMarkup.contentBindingEndMarker(12, "foobar"))).to.equal(false); + }); + + it("parseContentBindingStartMarker should return null when not provided a start marker", () => { + expect(HydrationMarkup.parseContentBindingStartMarker(HydrationMarkup.contentBindingEndMarker(12, "foobar"))).to.equal(null) + }) + it("parseContentBindingStartMarker should the index and id arguments to contentBindingStartMarker", () => { + expect(HydrationMarkup.parseContentBindingStartMarker(HydrationMarkup.contentBindingStartMarker(12, "foobar"))).to.eql([12, "foobar"]) + }); + it("parseContentBindingEndMarker should return null when not provided an end marker", () => { + expect(HydrationMarkup.parseContentBindingEndMarker(HydrationMarkup.contentBindingStartMarker(12, "foobar"))).to.equal(null) + }) + it("parseContentBindingEndMarker should the index and id arguments to contentBindingEndMarker", () => { + expect(HydrationMarkup.parseContentBindingEndMarker(HydrationMarkup.contentBindingEndMarker(12, "foobar"))).to.eql([12, "foobar"]) + }); + }); + + describe("attribute binding parser", () => { + it("should return null when the element does not have an attribute marker", () => { + expect(HydrationMarkup.parseAttributeBinding(document.createElement("div"))).to.equal(null) + }); + it("should return the binding ids as numbers when assigned a marker attribute", () => { + const el = document.createElement("div"); + el.setAttribute(HydrationMarkup.attributeMarkerName, "0 1 2"); + expect(HydrationMarkup.parseAttributeBinding(el)).to.eql([0, 1, 2]); + }); + }); + + describe("repeat parser", () => { + it("isRepeatViewStartMarker should return true when provided the output of repeatStartMarker", () => { + expect(HydrationMarkup.isRepeatViewStartMarker(HydrationMarkup.repeatStartMarker(12))).to.equal(true); + }); + it("isRepeatViewStartMarker should return false when provided the output of repeatEndMarker", () => { + expect(HydrationMarkup.isRepeatViewStartMarker(HydrationMarkup.repeatEndMarker(12))).to.equal(false); + }); + it("isRepeatViewEndMarker should return true when provided the output of repeatEndMarker", () => { + expect(HydrationMarkup.isRepeatViewEndMarker(HydrationMarkup.repeatEndMarker(12))).to.equal(true); + }); + it("isRepeatViewEndMarker should return false when provided the output of repeatStartMarker", () => { + expect(HydrationMarkup.isRepeatViewEndMarker(HydrationMarkup.repeatStartMarker(12))).to.equal(false); + }); + + it("parseRepeatStartMarker should return null when not provided a start marker", () => { + expect(HydrationMarkup.parseRepeatStartMarker(HydrationMarkup.repeatEndMarker(12))).to.equal(null) + }) + it("parseRepeatStartMarker should the index and id arguments to repeatStartMarker", () => { + expect(HydrationMarkup.parseRepeatStartMarker(HydrationMarkup.repeatStartMarker(12))).to.eql(12) + }); + it("parseRepeatEndMarker should return null when not provided an end marker", () => { + expect(HydrationMarkup.parseRepeatEndMarker(HydrationMarkup.repeatStartMarker(12))).to.equal(null) + }) + it("parseRepeatEndMarker should the index and id arguments to repeatEndMarker", () => { + expect(HydrationMarkup.parseRepeatEndMarker(HydrationMarkup.repeatEndMarker(12))).to.eql(12) + }); + }) +}) diff --git a/packages/web-components/fast-element/src/components/hydration.ts b/packages/web-components/fast-element/src/components/hydration.ts index 91f32496f47..30b2193ff23 100644 --- a/packages/web-components/fast-element/src/components/hydration.ts +++ b/packages/web-components/fast-element/src/components/hydration.ts @@ -1,44 +1,143 @@ -import { UnobservableMutationObserver } from "../utilities.js"; -import { ElementController } from "./element-controller.js"; +import type { + ContentTemplate, + HydratableContentTemplate, +} from "../templating/html-binding-directive.js"; +import type { ViewController } from "../templating/html-directive.js"; +import type { + ElementViewTemplate, + HydratableElementViewTemplate, + HydratableSyntheticViewTemplate, + SyntheticViewTemplate, +} from "../templating/template.js"; +import type { HydrationView } from "../templating/view.js"; -const deferHydrationAttribute = "defer-hydration"; +const bindingStartMarker = /fe-b\$\$start\$\$(\d+)\$\$(.+)\$\$fe-b/; +const bindingEndMarker = /fe-b\$\$end\$\$(\d+)\$\$(.+)\$\$fe-b/; +const repeatViewStartMarker = /fe-repeat\$\$start\$\$(\d+)\$\$fe-repeat/; +const repeatViewEndMarker = /fe-repeat\$\$end\$\$(\d+)\$\$fe-repeat/; +const elementBoundaryStartMarker = /fe-eb\$\$start\$\$(.+)\$\$fe-eb/; +const elementBoundaryEndMarker = /fe-eb\$\$end\$\$(.+)\$\$fe-eb/; + +function isComment(node: Node): node is Comment { + return node && node.nodeType === Node.COMMENT_NODE; +} + +/** + * Markup utilities to aid in template hydration. + * @internal + */ +export const HydrationMarkup = Object.freeze({ + attributeMarkerName: "data-fe-b", + attributeBindingSeparator: " ", + contentBindingStartMarker(index: number, uniqueId: string) { + return `fe-b$$start$$${index}$$${uniqueId}$$fe-b`; + }, + contentBindingEndMarker(index: number, uniqueId: string) { + return `fe-b$$end$$${index}$$${uniqueId}$$fe-b`; + }, + repeatStartMarker(index: number) { + return `fe-repeat$$start$$${index}$$fe-repeat`; + }, + repeatEndMarker(index: number) { + return `fe-repeat$$end$$${index}$$fe-repeat`; + }, + isContentBindingStartMarker(content: string) { + return bindingStartMarker.test(content); + }, + isContentBindingEndMarker(content: string) { + return bindingEndMarker.test(content); + }, + isRepeatViewStartMarker(content: string) { + return repeatViewStartMarker.test(content); + }, + isRepeatViewEndMarker(content: string) { + return repeatViewEndMarker.test(content); + }, + isElementBoundaryStartMarker(node: Node) { + return isComment(node) && elementBoundaryStartMarker.test(node.data); + }, + isElementBoundaryEndMarker(node: Node) { + return isComment(node) && elementBoundaryEndMarker.test(node.data); + }, + /** + * Returns the indexes of the ViewBehaviorFactories affecting + * attributes for the element, or null if no factories were found. + */ + parseAttributeBinding(node: Element): null | number[] { + const attr = node.getAttribute(this.attributeMarkerName); + return attr === null + ? attr + : attr.split(this.attributeBindingSeparator).map(i => parseInt(i)); + }, + /** + * Parses the ViewBehaviorFactory index from string data. Returns + * the binding index or null if the index cannot be retrieved. + */ + parseContentBindingStartMarker(content: string): null | [index: number, id: string] { + return parseIndexAndIdMarker(bindingStartMarker, content); + }, + parseContentBindingEndMarker(content: string): null | [index: number, id: string] { + return parseIndexAndIdMarker(bindingEndMarker, content); + }, + + /** + * Parses the index of a repeat directive from a content string. + */ + parseRepeatStartMarker(content: string): null | number { + return parseIntMarker(repeatViewStartMarker, content); + }, + parseRepeatEndMarker(content: string): null | number { + return parseIntMarker(repeatViewEndMarker, content); + }, + /** + * Parses element Id from element boundary markers + */ + parseElementBoundaryStartMarker(content: string): null | string { + return parseStringMarker(elementBoundaryStartMarker, content); + }, + parseElementBoundaryEndMarker(content: string): null | string { + return parseStringMarker(elementBoundaryEndMarker, content); + }, +}); + +function parseIntMarker(regex: RegExp, content: string): null | number { + const match = regex.exec(content); + return match === null ? match : parseInt(match[1]); +} + +function parseStringMarker(regex: RegExp, content: string): string | null { + const match = regex.exec(content); + return match === null ? match : match[1]; +} + +function parseIndexAndIdMarker( + regex: RegExp, + content: string +): null | [index: number, id: string] { + const match = regex.exec(content); + return match === null ? match : [parseInt(match[1]), match[2]]; +} + +/** + * @internal + */ +export const Hydratable = Symbol.for("fe-hydration"); /** - * An ElementController capable of hydrating FAST elements from - * Declarative Shadow DOM. + * Tests if a template or ViewController is hydratable. * * @beta */ -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); - } - - public static install() { - ElementController.setStrategy(HydratableElementController); - } +export function isHydratable(view: ViewController): view is HydrationView; +export function isHydratable( + template: SyntheticViewTemplate +): template is HydratableSyntheticViewTemplate; +export function isHydratable( + template: ElementViewTemplate +): template is HydratableElementViewTemplate; +export function isHydratable( + template: ContentTemplate +): template is HydratableContentTemplate; +export function isHydratable(value: any): any { + return value[Hydratable] === Hydratable; } diff --git a/packages/web-components/fast-element/src/components/install-hydration.ts b/packages/web-components/fast-element/src/components/install-hydration.ts index b8e329c6451..842efd8bd59 100644 --- a/packages/web-components/fast-element/src/components/install-hydration.ts +++ b/packages/web-components/fast-element/src/components/install-hydration.ts @@ -1,3 +1,4 @@ -import { HydratableElementController } from "./hydration.js"; +import "../templating/install-hydratable-view-templates.js"; +import { HydratableElementController } from "./element-controller.js"; HydratableElementController.install(); diff --git a/packages/web-components/fast-element/src/hydration/target-builder.ts b/packages/web-components/fast-element/src/hydration/target-builder.ts new file mode 100644 index 00000000000..0636a0c955a --- /dev/null +++ b/packages/web-components/fast-element/src/hydration/target-builder.ts @@ -0,0 +1,270 @@ +import { HydrationMarkup } from "../components/hydration.js"; +import type { + CompiledViewBehaviorFactory, + ViewBehaviorFactory, + ViewBehaviorTargets, +} from "../templating/html-directive.js"; + +export class HydrationTargetElementError extends Error { + /** + * String representation of the HTML in the template that + * threw the target element error. + */ + public templateString?: string; + + constructor( + /** + * The error message + */ + message: string | undefined, + /** + * The Compiled View Behavior Factories that belong to the view. + */ + public readonly factories: CompiledViewBehaviorFactory[], + /** + * The node to target factory. + */ + public readonly node: Element + ) { + super(message); + } +} + +/** + * Represents the DOM boundaries controlled by a view + */ +export interface ViewBoundaries { + first: Node; + last: Node; +} + +/** + * Stores relationships between a {@link ViewBehaviorFactory} and + * the {@link ViewBoundaries} the factory created. + */ +export interface ViewBehaviorBoundaries { + [factoryId: string]: ViewBoundaries; +} + +function isComment(node: Node): node is Comment { + return node.nodeType === Node.COMMENT_NODE; +} + +function isText(node: Node): node is Text { + return node.nodeType === Node.TEXT_NODE; +} + +/** + * Returns a range object inclusive of all nodes including and between the + * provided first and last node. + * @param first - The first node + * @param last - This last node + * @returns + */ +export function createRangeForNodes(first: Node, last: Node): Range { + const range = document.createRange(); + range.setStart(first, 0); + + // The lastIndex should be inclusive of the end of the lastChild. Obtain offset based + // on usageNotes: https://developer.mozilla.org/en-US/docs/Web/API/Range/setEnd#usage_notes + range.setEnd( + last, + isComment(last) || isText(last) ? last.data.length : last.childNodes.length + ); + return range; +} + +function isShadowRoot(node: Node): node is ShadowRoot { + return node instanceof DocumentFragment && "mode" in node; +} + +/** + * Maps {@link CompiledViewBehaviorFactory} ids to the corresponding node targets for the view. + * @param firstNode - The first node of the view. + * @param lastNode - The last node of the view. + * @param factories - The Compiled View Behavior Factories that belong to the view. + * @returns - A {@link ViewBehaviorTargets } object for the factories in the view. + */ +export function buildViewBindingTargets( + firstNode: Node, + lastNode: Node, + factories: CompiledViewBehaviorFactory[] +): { targets: ViewBehaviorTargets; boundaries: ViewBehaviorBoundaries } { + const range = createRangeForNodes(firstNode, lastNode); + const treeRoot = range.commonAncestorContainer; + const walker = document.createTreeWalker( + treeRoot, + NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_COMMENT + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + } + ); + const targets: ViewBehaviorTargets = {}; + const boundaries: ViewBehaviorBoundaries = {}; + + let node: Node | null = (walker.currentNode = firstNode); + + while (node !== null) { + switch (node.nodeType) { + case Node.ELEMENT_NODE: { + targetElement(node as Element, factories, targets); + break; + } + + case Node.COMMENT_NODE: { + targetComment(node as Comment, walker, factories, targets, boundaries); + break; + } + } + + node = walker.nextNode(); + } + + range.detach(); + return { targets, boundaries }; +} + +function targetElement( + node: Element, + factories: CompiledViewBehaviorFactory[], + targets: ViewBehaviorTargets +) { + // Check for attributes and map any factories. + const attrFactoryIds = HydrationMarkup.parseAttributeBinding(node); + + if (attrFactoryIds !== null) { + for (const id of attrFactoryIds) { + if (!factories[id]) { + throw new HydrationTargetElementError( + `HydrationView was unable to successfully target factory on ${ + node.nodeName + } inside ${ + (node.getRootNode() as ShadowRoot).host.nodeName + }. This likely indicates a template mismatch between SSR rendering and hydration.`, + factories, + node + ); + } + targetFactory(factories[id], node, targets); + } + + node.removeAttribute(HydrationMarkup.attributeMarkerName); + } +} + +function targetComment( + node: Comment, + walker: TreeWalker, + factories: CompiledViewBehaviorFactory[], + targets: ViewBehaviorTargets, + boundaries: ViewBehaviorBoundaries +) { + if (HydrationMarkup.isElementBoundaryStartMarker(node)) { + skipToElementBoundaryEndMarker(node, walker); + return; + } + + if (HydrationMarkup.isContentBindingStartMarker(node.data)) { + const parsed = HydrationMarkup.parseContentBindingStartMarker(node.data); + + if (parsed === null) { + return; + } + + const [index, id] = parsed; + + const factory = factories[index]; + const nodes: Node[] = []; + let current: Node | null = walker.nextSibling(); + node.data = ""; + const first = current!; + + // Search for the binding end marker that closes the binding. + while (current !== null) { + if (isComment(current)) { + const parsed = HydrationMarkup.parseContentBindingEndMarker(current.data); + + if (parsed && parsed[1] === id) { + break; + } + } + + nodes.push(current); + current = walker.nextSibling(); + } + + if (current === null) { + const root = node.getRootNode(); + throw new Error( + `Error hydrating Comment node inside "${ + isShadowRoot(root) ? root.host.nodeName : root.nodeName + }".` + ); + } + + (current as Comment).data = ""; + if (nodes.length === 1 && isText(nodes[0])) { + targetFactory(factory, nodes[0], targets); + } else { + // If current === first, it means there is no content in + // the view. This happens when a `when` directive evaluates false, + // or whenever a content binding returns null or undefined. + // In that case, there will never be any content + // to hydrate and Binding can simply create a HTMLView + // whenever it needs to. + if (current !== first && current.previousSibling !== null) { + boundaries[factory.targetNodeId] = { + first, + last: current.previousSibling, + }; + } + // Binding evaluates to null / undefined or a template. + // If binding revaluates to string, it will replace content in target + // So we always insert a text node to ensure that + // text content binding will be written to this text node instead of comment + const dummyTextNode = current.parentNode!.insertBefore( + document.createTextNode(""), + current + ); + targetFactory(factory, dummyTextNode, targets); + } + } +} + +/** + * Moves TreeWalker to element boundary end marker + * @param node - element boundary start marker node + * @param walker - tree walker + */ +function skipToElementBoundaryEndMarker(node: Comment, walker: TreeWalker) { + const id = HydrationMarkup.parseElementBoundaryStartMarker(node.data); + let current = walker.nextSibling(); + + while (current !== null) { + if (isComment(current)) { + const parsed = HydrationMarkup.parseElementBoundaryEndMarker(current.data); + if (parsed && parsed === id) { + break; + } + } + + current = walker.nextSibling(); + } +} + +export function targetFactory( + factory: ViewBehaviorFactory, + node: Node, + targets: ViewBehaviorTargets +): void { + if (factory.targetNodeId === undefined) { + // Dev error, this shouldn't ever be thrown + throw new Error("Factory could not be target to the node"); + } + + targets[factory.targetNodeId] = node; +} diff --git a/packages/web-components/fast-element/src/index.ts b/packages/web-components/fast-element/src/index.ts index 1a59cd46d46..4001cce2b23 100644 --- a/packages/web-components/fast-element/src/index.ts +++ b/packages/web-components/fast-element/src/index.ts @@ -7,7 +7,6 @@ export type { FASTGlobal, TrustedTypesPolicy, } from "./interfaces.js"; - export { FAST, emptyArray } from "./platform.js"; // DOM @@ -119,13 +118,21 @@ export { ChildListDirectiveOptions, SubtreeDirectiveOptions, } from "./templating/children.js"; -export { ElementView, HTMLView, SyntheticView, View } from "./templating/view.js"; +export { + ElementView, + HTMLView, + SyntheticView, + View, + HydratableView, + HydrationBindingError, +} from "./templating/view.js"; export { elements, ElementsFilter, NodeBehaviorOptions, NodeObservationDirective, } from "./templating/node-observation.js"; +export { render, RenderBehavior, RenderDirective } from "./templating/render.js"; // Components export { customElement, FASTElement } from "./components/fast-element.js"; @@ -148,4 +155,5 @@ export { export { ElementController, ElementControllerStrategy, + HydratableElementController, } from "./components/element-controller.js"; diff --git a/packages/web-components/fast-element/src/templating/binding.spec.ts b/packages/web-components/fast-element/src/templating/binding.spec.ts index 4ed74039e48..77c1abd25cf 100644 --- a/packages/web-components/fast-element/src/templating/binding.spec.ts +++ b/packages/web-components/fast-element/src/templating/binding.spec.ts @@ -293,7 +293,7 @@ describe("The HTML binding directive", () => { expect(toHTML(parentNode)).to.equal(`This is a template. testing...`); }); - it("allows interpolated HTML tags in templates using dangerousHTML", async () => { + it("allows interpolated HTML tags in templates using html.partial", async () => { const { behavior, parentNode, targets } = contentBinding(); const template = html`${x => html`<${html.partial(x.knownValue)}>Hi there!`}`; const model = new Model(template); diff --git a/packages/web-components/fast-element/src/templating/children.spec.ts b/packages/web-components/fast-element/src/templating/children.spec.ts index a1a5f9f1153..97226891c38 100644 --- a/packages/web-components/fast-element/src/templating/children.spec.ts +++ b/packages/web-components/fast-element/src/templating/children.spec.ts @@ -6,7 +6,6 @@ import { Updates } from "../observation/update-queue.js"; import { Fake } from "../testing/fakes.js"; import { html } from "./template.js"; import { ref } from "./ref.js"; -import { computedState } from "../state/state.js"; describe("The children", () => { context("template function", () => { @@ -186,27 +185,6 @@ describe("The children", () => { expect(model.nodes).members([]); }); - it("re-watches when re-bound", async () => { - const { host, children, targets, nodeId } = createDOM("foo-bar"); - const behavior = new ChildrenDirective({ - property: "nodes", - }); - behavior.targetNodeId = nodeId; - const model = new Model(); - const controller = Fake.viewController(targets, behavior); - - controller.bind(model); - - behavior.unbind(controller); - behavior.bind(controller); - - const element = document.createElement("div"); - host.appendChild(element); - - await Updates.next(); - - expect(model.nodes.includes(element)).to.equal(true) - }); it("should not throw if DOM stringified", () => { const template = html` @@ -228,7 +206,6 @@ describe("The children", () => { view.unbind(); }); - it("supports multiple directives for the same element", async () => { const { host, targets, nodeId } = createDOM("foo-bar"); class MultipleDirectivesModel { diff --git a/packages/web-components/fast-element/src/templating/compiler.ts b/packages/web-components/fast-element/src/templating/compiler.ts index 93512a1b9ea..6f332929722 100644 --- a/packages/web-components/fast-element/src/templating/compiler.ts +++ b/packages/web-components/fast-element/src/templating/compiler.ts @@ -2,8 +2,8 @@ import { isFunction, isString, Message } from "../interfaces.js"; import type { ExecutionContext } from "../observation/observable.js"; import { FAST } from "../platform.js"; import { DOM, DOMPolicy } from "../dom.js"; -import type { Binding } from "../binding/binding.js"; import { oneTime } from "../binding/one-time.js"; +import { oneWay } from "../binding/one-way.js"; import { nextId, Parser } from "./markup.js"; import { HTMLBindingDirective } from "./html-binding-directive.js"; import { @@ -416,7 +416,6 @@ export const Compiler = { } let sourceAspect!: string; - let binding!: Binding; let isVolatile: boolean | undefined = false; let bindingPolicy: DOMPolicy | undefined = void 0; const partCount = parts.length; @@ -427,7 +426,6 @@ export const Compiler = { } sourceAspect = (x as any as Aspected).sourceAspect || sourceAspect; - binding = (x as any as Aspected).dataBinding || binding; isVolatile = isVolatile || (x as any as Aspected).dataBinding!.isVolatile; bindingPolicy = bindingPolicy || (x as any as Aspected).dataBinding!.policy; return (x as any as Aspected).dataBinding!.evaluate; @@ -443,10 +441,10 @@ export const Compiler = { return output; }; - binding.evaluate = expression; - binding.isVolatile = isVolatile; - binding.policy = bindingPolicy ?? policy; - const directive = new HTMLBindingDirective(binding); + const directive = new HTMLBindingDirective( + oneWay(expression, bindingPolicy ?? policy, isVolatile) + ); + HTMLDirective.assignAspect(directive, sourceAspect!); return directive; }, diff --git a/packages/web-components/fast-element/src/templating/html-binding-directive.ts b/packages/web-components/fast-element/src/templating/html-binding-directive.ts index f396357dc62..1a04825c0ca 100644 --- a/packages/web-components/fast-element/src/templating/html-binding-directive.ts +++ b/packages/web-components/fast-element/src/templating/html-binding-directive.ts @@ -1,3 +1,5 @@ +import { isHydratable } from "../components/hydration.js"; +import { DOM, DOMAspect, DOMPolicy } from "../dom.js"; import { Message } from "../interfaces.js"; import { ExecutionContext, @@ -5,7 +7,6 @@ import { ExpressionObserver, } from "../observation/observable.js"; import { FAST } from "../platform.js"; -import { DOM, DOMAspect, DOMPolicy } from "../dom.js"; import type { Binding, BindingDirective } from "../binding/binding.js"; import { AddViewBehaviorFactory, @@ -16,6 +17,7 @@ import { ViewController, } from "./html-directive.js"; import { Markup } from "./markup.js"; +import { HydrationStage } from "./view.js"; type UpdateTarget = ( this: HTMLBindingDirective, @@ -68,6 +70,13 @@ export interface ContentTemplate { create(): ContentView; } +export interface HydratableContentTemplate extends ContentTemplate { + /** + * Hydrates a content view from first/last nodes. + */ + hydrate(first: Node, last: Node): ContentView; +} + type ComposableView = ContentView & { isComposed?: boolean; needsBindOnly?: boolean; @@ -78,7 +87,12 @@ type ContentTarget = Node & { $fastTemplate?: ContentTemplate; }; +function isContentTemplate(value: any): value is ContentTemplate { + return value.create !== undefined; +} + function updateContent( + this: HTMLBindingDirective, target: ContentTarget, aspect: string, value: any, @@ -91,14 +105,24 @@ function updateContent( } // If the value has a "create" method, then it's a ContentTemplate. - if (value.create) { + if (isContentTemplate(value)) { target.textContent = ""; let view = target.$fastView as ComposableView; // If there's no previous view that we might be able to // reuse then create a new view from the template. if (view === void 0) { - view = value.create(); + if ( + isHydratable(controller) && + isHydratable(value) && + controller.bindingViewBoundaries[this.targetNodeId] !== undefined && + controller.hydrationStage !== HydrationStage.hydrated + ) { + const viewNodes = controller.bindingViewBoundaries[this.targetNodeId]; + view = value.hydrate(viewNodes.first, viewNodes.last); + } else { + view = value.create(); + } } else { // If there is a previous view, but it wasn't created // from the same template as the new value, then we @@ -297,6 +321,10 @@ export class HTMLBindingDirective /** @internal */ bind(controller: ViewController): void { const target = controller.targets[this.targetNodeId]; + const isHydrating = + isHydratable(controller) && + controller.hydrationStage && + controller.hydrationStage !== HydrationStage.hydrated; switch (this.aspectType) { case DOMAspect.event: @@ -318,6 +346,16 @@ export class HTMLBindingDirective (observer as any).target = target; (observer as any).controller = controller; + if ( + isHydrating && + (this.aspectType === DOMAspect.attribute || + this.aspectType === DOMAspect.booleanAttribute) + ) { + observer.bind(controller); + // Skip updating target during bind for attributes + break; + } + this.updateTarget!( target, this.targetAspect, diff --git a/packages/web-components/fast-element/src/templating/install-hydratable-view-templates.ts b/packages/web-components/fast-element/src/templating/install-hydratable-view-templates.ts new file mode 100644 index 00000000000..ba5e74222bc --- /dev/null +++ b/packages/web-components/fast-element/src/templating/install-hydratable-view-templates.ts @@ -0,0 +1,23 @@ +import { Hydratable } from "../components/hydration.js"; +import { ViewTemplate } from "./template.js"; +import { HydrationView } from "./view.js"; + +// Configure ViewTemplate to be hydratable by attaching a symbol identifier +// and a hydrate method. Augmenting the hydration features is done by +// property assignment instead of class extension to better allow the +// hydration feature to be tree-shaken. +Object.defineProperties(ViewTemplate.prototype, { + [Hydratable]: { value: Hydratable, enumerable: false, configurable: false }, + hydrate: { + value: function ( + this: ViewTemplate, + firstChild: Node, + lastChild: Node, + hostBindingTarget?: Element + ): HydrationView { + return new HydrationView(firstChild, lastChild, this, hostBindingTarget); + }, + enumerable: true, + configurable: false, + }, +}); diff --git a/packages/web-components/fast-element/src/templating/render.spec.ts b/packages/web-components/fast-element/src/templating/render.spec.ts index 8baac5de0b1..c153f22e1f2 100644 --- a/packages/web-components/fast-element/src/templating/render.spec.ts +++ b/packages/web-components/fast-element/src/templating/render.spec.ts @@ -16,9 +16,9 @@ import { children } from "./children.js"; import { elements } from "./node-observation.js"; describe("The render", () => { - const childTemplate = html`Child Template`; - const childEditTemplate = html`Child Edit Template`; - const parentTemplate = html`Parent Template`; + const childTemplate = html`

Child Template

`; + const childEditTemplate = html`

Child Edit Template

`; + const parentTemplate = html`

Parent Template

`; context("template function", () => { class TestChild { diff --git a/packages/web-components/fast-element/src/templating/render.ts b/packages/web-components/fast-element/src/templating/render.ts index d1f4aedcf97..996799721a7 100644 --- a/packages/web-components/fast-element/src/templating/render.ts +++ b/packages/web-components/fast-element/src/templating/render.ts @@ -1,5 +1,6 @@ import { FASTElementDefinition } from "../components/fast-definitions.js"; import type { FASTElement } from "../components/fast-element.js"; +import { isHydratable } from "../components/hydration.js"; import type { DOMPolicy } from "../dom.js"; import { Constructable, isFunction, isString } from "../interfaces.js"; import { Binding, BindingDirective } from "../binding/binding.js"; @@ -28,6 +29,7 @@ import { TemplateValue, ViewTemplate, } from "./template.js"; +import { HydrationStage } from "./view.js"; type ComposableView = ContentView & { isComposed?: boolean; @@ -70,7 +72,23 @@ export class RenderBehavior implements ViewBehavior, Subscriber { this.data = this.dataBindingObserver.bind(controller); this.template = this.templateBindingObserver.bind(controller); controller.onUnbind(this); - this.refreshView(); + + if ( + isHydratable(this.template) && + isHydratable(controller) && + controller.hydrationStage !== HydrationStage.hydrated && + !this.view + ) { + const viewNodes = + controller.bindingViewBoundaries[this.directive.targetNodeId]; + + if (viewNodes) { + this.view = this.template.hydrate(viewNodes.first, viewNodes.last); + this.bindView(this.view); + } + } else { + this.refreshView(); + } } /** @@ -102,6 +120,20 @@ export class RenderBehavior implements ViewBehavior, Subscriber { this.refreshView(); } + private bindView(view: ComposableView) { + // It's possible that the value is the same as the previous template + // and that there's actually no need to compose it. + if (!view.isComposed) { + view.isComposed = true; + view.bind(this.data); + view.insertBefore(this.location!); + view.$fastTemplate = this.template; + } else if (view.needsBindOnly) { + view.needsBindOnly = false; + view.bind(this.data); + } + } + private refreshView() { let view = this.view; const template = this.template; @@ -127,17 +159,7 @@ export class RenderBehavior implements ViewBehavior, Subscriber { } } - // It's possible that the value is the same as the previous template - // and that there's actually no need to compose it. - if (!view.isComposed) { - view.isComposed = true; - view.bind(this.data); - view.insertBefore(this.location!); - view.$fastTemplate = template; - } else if (view.needsBindOnly) { - view.needsBindOnly = false; - view.bind(this.data); - } + this.bindView(view); } } @@ -150,7 +172,7 @@ export class RenderDirective { /** * The structural id of the DOM node to which the created behavior will apply. - */ BindingDirective; + */ public targetNodeId: string; /** @@ -327,7 +349,7 @@ const typeToInstructionLookup = new Map< >(); /* eslint @typescript-eslint/naming-convention: "off"*/ -const defaultAttributes = { ":model": x => x }; +const defaultAttributes = { ":model": (x: any) => x }; const brand = Symbol("RenderInstruction"); const defaultViewName = "default-view"; const nullTemplate = html` @@ -615,6 +637,10 @@ export class NodeTemplate implements ContentTemplate, ContentView { create(): ContentView { return this; } + + hydrate(first: Node, last: Node): ContentView { + return this; + } } /** diff --git a/packages/web-components/fast-element/src/templating/repeat.ts b/packages/web-components/fast-element/src/templating/repeat.ts index 3c773039e83..b840820811f 100644 --- a/packages/web-components/fast-element/src/templating/repeat.ts +++ b/packages/web-components/fast-element/src/templating/repeat.ts @@ -1,10 +1,10 @@ +import { HydrationMarkup, isHydratable } from "../components/hydration.js"; +import { ArrayObserver, Splice } from "../observation/arrays.js"; import type { Notifier, Subscriber } from "../observation/notifier.js"; import { Expression, ExpressionObserver, Observable } from "../observation/observable.js"; import { emptyArray } from "../platform.js"; -import { ArrayObserver, Splice } from "../observation/arrays.js"; import type { Binding, BindingDirective } from "../binding/binding.js"; import { normalizeBinding } from "../binding/normalize.js"; -import { Markup } from "./markup.js"; import { AddViewBehaviorFactory, HTMLDirective, @@ -12,8 +12,14 @@ import { ViewBehaviorFactory, ViewController, } from "./html-directive.js"; -import type { CaptureType, SyntheticViewTemplate, ViewTemplate } from "./template.js"; -import { HTMLView, SyntheticView } from "./view.js"; +import { Markup } from "./markup.js"; +import type { + CaptureType, + HydratableSyntheticViewTemplate, + SyntheticViewTemplate, + ViewTemplate, +} from "./template.js"; +import { HTMLView, HydrationStage, HydrationView, SyntheticView } from "./view.js"; /** * Options for configuring repeat behavior. @@ -42,8 +48,8 @@ function bindWithoutPositioning( index: number, controller: ViewController ): void { - view.context.parent = controller!.source; - view.context.parentContext = controller!.context; + view.context.parent = controller.source; + view.context.parentContext = controller.context; view.bind(items[index]); } @@ -53,13 +59,35 @@ function bindWithPositioning( index: number, controller: ViewController ): void { - view.context.parent = controller!.source; - view.context.parentContext = controller!.context; + view.context.parent = controller.source; + view.context.parentContext = controller.context; view.context.length = items.length; view.context.index = index; view.bind(items[index]); } +function isCommentNode(node: Node): node is Comment { + return node.nodeType === Node.COMMENT_NODE; +} + +export class HydrationRepeatError extends Error { + constructor( + /** + * The error message + */ + message: string | undefined, + public readonly propertyBag: { + index: number; + hydrationStage: string; + itemsLength?: number; + viewsState: string[]; + viewTemplateString?: string; + rootNodeContent: string; + } + ) { + super(message); + } +} /** * A behavior that renders a template for each item in an array. * @public @@ -109,7 +137,17 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { this.items = this.itemsBindingObserver.bind(controller); this.template = this.templateBindingObserver.bind(controller); this.observeItems(true); - this.refreshAllViews(); + + if ( + isHydratable(this.template) && + isHydratable(controller) && + controller.hydrationStage !== HydrationStage.hydrated + ) { + this.hydrateViews(this.template); + } else { + this.refreshAllViews(); + } + controller.onUnbind(this); } @@ -262,6 +300,27 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { for (; i < itemsLength; ++i) { if (i < viewsLength) { const view = views[i]; + if (!view) { + const serializer = new XMLSerializer(); + throw new HydrationRepeatError( + `View is null or undefined inside "${ + (this.location.getRootNode() as ShadowRoot).host.nodeName + }".`, + { + index: i, + hydrationStage: (this.controller as HydrationView) + .hydrationStage, + itemsLength, + viewsState: views.map(v => (v ? "hydrated" : "empty")), + viewTemplateString: serializer.serializeToString( + (template.create() as any).fragment + ), + rootNodeContent: serializer.serializeToString( + this.location.getRootNode() as any + ), + } + ); + } bindView(view, items, i, controller); } else { const view = template.create(); @@ -283,7 +342,100 @@ export class RepeatBehavior implements ViewBehavior, Subscriber { const views = this.views; for (let i = 0, ii = views.length; i < ii; ++i) { - views[i].unbind(); + const view = views[i]; + if (!view) { + const serializer = new XMLSerializer(); + throw new HydrationRepeatError( + `View is null or undefined inside "${ + (this.location.getRootNode() as ShadowRoot).host.nodeName + }".`, + { + index: i, + hydrationStage: (this.controller as HydrationView).hydrationStage, + viewsState: views.map(v => (v ? "hydrated" : "empty")), + rootNodeContent: serializer.serializeToString( + this.location.getRootNode() as any + ), + } + ); + } + view.unbind(); + } + } + + private hydrateViews(template: HydratableSyntheticViewTemplate) { + if (!this.items) { + return; + } + + this.views = new Array(this.items.length); + let current = this.location.previousSibling; + + while (current !== null) { + if (!isCommentNode(current)) { + current = current.previousSibling; + continue; + } + const index = HydrationMarkup.parseRepeatEndMarker(current.data); + if (index === null) { + current = current.previousSibling; + continue; + } + current.data = ""; + // end of repeat is the previousSibling of end comment + const end = current.previousSibling; + + if (!end) { + throw new Error( + `Error when hydrating inside "${ + (this.location.getRootNode() as ShadowRoot).host.nodeName + }": end should never be null.` + ); + } + + // find start marker + let start: Node | null = end; + // How many unmatched end markers we've encountered + let unmatchedEndMarkers = 0; + while (start !== null) { + if (isCommentNode(start)) { + if (HydrationMarkup.isRepeatViewEndMarker(start.data)) { + unmatchedEndMarkers++; + } else if (HydrationMarkup.isRepeatViewStartMarker(start.data)) { + if (unmatchedEndMarkers) { + unmatchedEndMarkers--; + } else { + if ( + HydrationMarkup.parseRepeatStartMarker(start.data) !== + index + ) { + throw new Error( + `Error when hydrating inside "${ + (this.location.getRootNode() as ShadowRoot).host + .nodeName + }": Mismatched start and end markers.` + ); + } + start.data = ""; + current = start.previousSibling; + // start of repeat content is the nextSibling of start comment + start = start.nextSibling!; + const view = template.hydrate(start, end); + this.views[index] = view; + this.bindView(view, this.items, index, this.controller); + break; + } + } + } + start = start.previousSibling; + } + if (!start) { + throw new Error( + `Error when hydrating inside "${ + (this.location.getRootNode() as ShadowRoot).host.nodeName + }": start should never be null.` + ); + } } } } diff --git a/packages/web-components/fast-element/src/templating/template.spec.ts b/packages/web-components/fast-element/src/templating/template.spec.ts index ba822144bc7..4725fb03feb 100644 --- a/packages/web-components/fast-element/src/templating/template.spec.ts +++ b/packages/web-components/fast-element/src/templating/template.spec.ts @@ -535,7 +535,7 @@ describe(`The html tag template helper`, () => { expect(target.querySelector('#embedded')).to.be.equal(null) }); - it("Should properly interpolate HTML tags with opening / closing tags using dangerousHTML", () => { + it("Should properly interpolate HTML tags with opening / closing tags using html.partial", () => { const element = html.partial("button"); const template = html`<${element}>` expect(template.html).to.equal('') diff --git a/packages/web-components/fast-element/src/templating/template.ts b/packages/web-components/fast-element/src/templating/template.ts index 95453591a55..20c91b000ca 100644 --- a/packages/web-components/fast-element/src/templating/template.ts +++ b/packages/web-components/fast-element/src/templating/template.ts @@ -43,6 +43,15 @@ export interface ElementViewTemplate { ): ElementView; } +export interface HydratableElementViewTemplate + extends ElementViewTemplate { + hydrate( + firstChild: Node, + lastChild: Node, + hostBindingTarget?: Element + ): ElementView; +} + /** * A marker interface used to capture types when interpolating Directive helpers * into templates. @@ -67,6 +76,11 @@ export interface SyntheticViewTemplate { inline(): CaptureType; } +export interface HydratableSyntheticViewTemplate + extends SyntheticViewTemplate { + hydrate(firstChild: Node, lastChild: Node): SyntheticView; +} + /** * The result of a template compilation operation. * @public @@ -77,6 +91,8 @@ export interface HTMLTemplateCompilationResult { * @param hostBindingTarget - The host binding target for the view. */ createView(hostBindingTarget?: Element): HTMLView; + + readonly factories: CompiledViewBehaviorFactory[]; } // Much thanks to LitHTML for working this out! @@ -185,10 +201,9 @@ export class ViewTemplate } /** - * Creates an HTMLView instance based on this template definition. - * @param hostBindingTarget - The element that host behaviors will be bound to. + * @internal */ - public create(hostBindingTarget?: Element): HTMLView { + public compile() { if (this.result === null) { this.result = Compiler.compile( this.html, @@ -197,7 +212,15 @@ export class ViewTemplate ); } - return this.result.createView(hostBindingTarget); + return this.result; + } + + /** + * Creates an HTMLView instance based on this template definition. + * @param hostBindingTarget - The element that host behaviors will be bound to. + */ + public create(hostBindingTarget?: Element): HTMLView { + return this.compile().createView(hostBindingTarget); } /** diff --git a/packages/web-components/fast-element/src/templating/view.ts b/packages/web-components/fast-element/src/templating/view.ts index 7f0c4fbce45..c8e4574c174 100644 --- a/packages/web-components/fast-element/src/templating/view.ts +++ b/packages/web-components/fast-element/src/templating/view.ts @@ -1,4 +1,12 @@ -import type { HostController } from "../index.js"; +import { Hydratable } from "../components/hydration.js"; +import { + buildViewBindingTargets, + createRangeForNodes, + HydrationTargetElementError, + targetFactory, + ViewBehaviorBoundaries, +} from "../hydration/target-builder.js"; +import type { ViewTemplate } from "../templating/template.js"; import type { Disposable } from "../interfaces.js"; import { ExecutionContext, @@ -9,6 +17,7 @@ import { makeSerializationNoop } from "../platform.js"; import type { CompiledViewBehaviorFactory, ViewBehavior, + ViewBehaviorFactory, ViewBehaviorTargets, ViewController, } from "./html-directive.js"; @@ -105,46 +114,21 @@ function removeNodeSequence(firstNode: Node, lastNode: Node): void { while (current !== lastNode) { next = current.nextSibling; + if (!next) { + throw new Error( + `Unmatched first/last child inside "${ + (lastNode.getRootNode() as ShadowRoot).host.nodeName + }".` + ); + } parent.removeChild(current); - current = next!; + current = next; } parent.removeChild(lastNode); } -/** - * The standard View implementation, which also implements ElementView and SyntheticView. - * @public - */ -export class HTMLView - implements - ElementView, - SyntheticView, - ExecutionContext -{ - private behaviors: ViewBehavior[] | null = null; - private unbindables: { unbind(controller: ViewController) }[] = []; - - /** - * The data that the view is bound to. - */ - public source: TSource | null = null; - - /** - * Indicates whether the controller is bound. - */ - public isBound = false; - - /** - * Indicates how the source's lifetime relates to the controller's lifetime. - */ - readonly sourceLifetime: SourceLifetime = SourceLifetime.unknown; - - /** - * The execution context the view is running within. - */ - public context: ExecutionContext = this; - +class DefaultExecutionContext implements ExecutionContext { /** * The index of the current item within a repeat context. */ @@ -225,6 +209,41 @@ export class HTMLView public eventTarget(): TTarget { return this.event.target! as TTarget; } +} + +/** + * The standard View implementation, which also implements ElementView and SyntheticView. + * @public + */ +export class HTMLView + extends DefaultExecutionContext + implements + ElementView, + SyntheticView, + ExecutionContext +{ + private behaviors: ViewBehavior[] | null = null; + private unbindables: { unbind(controller: ViewController): void }[] = []; + + /** + * The data that the view is bound to. + */ + public source: TSource | null = null; + + /** + * Indicates whether the controller is bound. + */ + public isBound = false; + + /** + * Indicates how the source's lifetime relates to the controller's lifetime. + */ + readonly sourceLifetime: SourceLifetime = SourceLifetime.unknown; + + /** + * The execution context the view is running within. + */ + public context: ExecutionContext = this; /** * The first DOM node in the range of nodes that make up the view. @@ -246,6 +265,7 @@ export class HTMLView private factories: ReadonlyArray, public readonly targets: ViewBehaviorTargets ) { + super(); this.firstChild = fragment.firstChild!; this.lastChild = fragment.lastChild!; } @@ -312,7 +332,7 @@ export class HTMLView } public onUnbind(behavior: { - unbind(controller: ViewController); + unbind(controller: ViewController): void; }): void { this.unbindables.push(behavior); } @@ -401,3 +421,277 @@ export class HTMLView makeSerializationNoop(HTMLView); Observable.defineProperty(HTMLView.prototype, "index"); Observable.defineProperty(HTMLView.prototype, "length"); + +/** @public */ +export interface HydratableView + extends ElementView, + SyntheticView, + DefaultExecutionContext { + [Hydratable]: symbol; + readonly bindingViewBoundaries: Record; + readonly hydrationStage: keyof typeof HydrationStage; +} + +export interface ViewNodes { + first: Node; + last: Node; +} + +export const HydrationStage = { + unhydrated: "unhydrated", + hydrating: "hydrating", + hydrated: "hydrated", +} as const; + +/** @public */ +export class HydrationBindingError extends Error { + constructor( + /** + * The error message + */ + message: string | undefined, + /** + * The factory that was unable to be bound + */ + public readonly factory: ViewBehaviorFactory, + /** + * A DocumentFragment containing a clone of the + * view's Nodes. + */ + public readonly fragment: DocumentFragment, + + /** + * String representation of the HTML in the template that + * threw the binding error. + */ + public readonly templateString: string + ) { + super(message); + } +} + +export class HydrationView + extends DefaultExecutionContext + implements HydratableView +{ + [Hydratable] = Hydratable; + public context: ExecutionContext = this; + public source: TSource | null = null; + public isBound = false; + public get hydrationStage() { + return this._hydrationStage; + } + public get targets() { + return this._targets; + } + public get bindingViewBoundaries() { + return this._bindingViewBoundaries; + } + public readonly sourceLifetime: SourceLifetime = SourceLifetime.unknown; + private unbindables: { unbind(controller: ViewController): void }[] = []; + private fragment: DocumentFragment | null = null; + private behaviors: ViewBehavior[] | null = null; + private factories: CompiledViewBehaviorFactory[]; + private _hydrationStage: keyof typeof HydrationStage = HydrationStage.unhydrated; + private _bindingViewBoundaries: ViewBehaviorBoundaries = {}; + private _targets: ViewBehaviorTargets = {}; + + constructor( + public readonly firstChild: Node, + public readonly lastChild: Node, + private sourceTemplate: ViewTemplate, + private hostBindingTarget?: Element + ) { + super(); + this.factories = sourceTemplate.compile().factories; + } + + /** + * no-op. Hydrated views are don't need to be moved from a documentFragment + * to the target node. + */ + public insertBefore(node: Node): void { + // No-op in cases where this is called before the view is removed, + // because the nodes will already be in the document and just need hydrating. + if (this.fragment === null) { + return; + } + + if (this.fragment.hasChildNodes()) { + node.parentNode!.insertBefore(this.fragment, node); + } else { + const end = this.lastChild!; + if (node.previousSibling === end) return; + + const parentNode = node.parentNode!; + let current = this.firstChild!; + let next; + + while (current !== end) { + next = current.nextSibling; + parentNode.insertBefore(current, node); + current = next!; + } + + parentNode.insertBefore(end, node); + } + } + + /** + * Appends the view to a node. In cases where this is called before the + * view has been removed, the method will no-op. + * @param node - the node to append the view to. + */ + public appendTo(node: Node): void { + if (this.fragment !== null) { + node.appendChild(this.fragment); + } + } + + public remove(): void { + const fragment = + this.fragment || (this.fragment = document.createDocumentFragment()); + const end = this.lastChild!; + let current = this.firstChild!; + let next; + + while (current !== end) { + next = current.nextSibling; + if (!next) { + throw new Error( + `Unmatched first/last child inside "${ + (end.getRootNode() as ShadowRoot).host.nodeName + }".` + ); + } + fragment.appendChild(current); + current = next; + } + + fragment.appendChild(end); + } + + public bind(source: TSource, context: ExecutionContext = this): void { + if (this.hydrationStage !== HydrationStage.hydrated) { + this._hydrationStage = HydrationStage.hydrating; + } + + if (this.source === source) { + return; + } + + let behaviors = this.behaviors; + + if (behaviors === null) { + this.source = source; + this.context = context; + try { + const { targets, boundaries } = buildViewBindingTargets( + this.firstChild, + this.lastChild, + this.factories + ); + this._targets = targets; + this._bindingViewBoundaries = boundaries; + } catch (error) { + if (error instanceof HydrationTargetElementError) { + let templateString = this.sourceTemplate.html; + if (typeof templateString !== "string") { + templateString = templateString.innerHTML; + } + error.templateString = templateString; + } + throw error; + } + this.behaviors = behaviors = new Array(this.factories.length); + const factories = this.factories; + + for (let i = 0, ii = factories.length; i < ii; ++i) { + const factory = factories[i]; + + if (factory.targetNodeId === "h" && this.hostBindingTarget) { + targetFactory(factory, this.hostBindingTarget, this._targets); + } + + // If the binding has been targeted or it is a host binding and the view has a hostBindingTarget + if (factory.targetNodeId in this.targets) { + const behavior = factory.createBehavior(); + behavior.bind(this); + behaviors[i] = behavior; + } else { + let templateString = this.sourceTemplate.html; + + if (typeof templateString !== "string") { + templateString = templateString.innerHTML; + } + + throw new HydrationBindingError( + `HydrationView was unable to successfully target bindings inside "${ + (this.firstChild?.getRootNode() as ShadowRoot).host?.nodeName + }".`, + factory, + createRangeForNodes( + this.firstChild, + this.lastChild + ).cloneContents(), + templateString + ); + } + } + } else { + if (this.source !== null) { + this.evaluateUnbindables(); + } + + this.isBound = false; + this.source = source; + this.context = context; + + for (let i = 0, ii = behaviors.length; i < ii; ++i) { + behaviors[i].bind(this); + } + } + + this.isBound = true; + this._hydrationStage = HydrationStage.hydrated; + } + + public unbind(): void { + if (!this.isBound || this.source === null) { + return; + } + + this.evaluateUnbindables(); + + this.source = null; + this.context = this; + this.isBound = false; + } + + /** + * Removes the view and unbinds its behaviors, disposing of DOM nodes afterward. + * Once a view has been disposed, it cannot be inserted or bound again. + */ + public dispose(): void { + removeNodeSequence(this.firstChild, this.lastChild); + this.unbind(); + } + + public onUnbind(behavior: { + unbind(controller: ViewController): void; + }) { + this.unbindables.push(behavior); + } + + private evaluateUnbindables() { + const unbindables = this.unbindables; + + for (let i = 0, ii = unbindables.length; i < ii; ++i) { + unbindables[i].unbind(this); + } + + unbindables.length = 0; + } +} + +makeSerializationNoop(HydrationView); diff --git a/packages/web-components/fast-ssr/docs/api-report.api.md b/packages/web-components/fast-ssr/docs/api-report.api.md index 5d3c14f8c11..617479ec2d3 100644 --- a/packages/web-components/fast-ssr/docs/api-report.api.md +++ b/packages/web-components/fast-ssr/docs/api-report.api.md @@ -6,14 +6,17 @@ /// +import { Aspected } from '@microsoft/fast-element'; import { AsyncLocalStorage } from 'async_hooks'; -import { Binding } from '@microsoft/fast-element'; -import { ComposableStyles } from '@microsoft/fast-element'; import { Constructable } from '@microsoft/fast-element'; +import { Disposable as Disposable_2 } from '@microsoft/fast-element'; import { DOMContainer } from '@microsoft/fast-element/di.js'; +import { EventEmitter } from 'node:events'; import { ExecutionContext } from '@microsoft/fast-element'; import { FASTElement } from '@microsoft/fast-element'; import { FASTElementDefinition } from '@microsoft/fast-element'; +import { HostController } from '@microsoft/fast-element'; +import { TemplateCacheController } from '../template-cache/controller.js'; import { ViewBehaviorFactory } from '@microsoft/fast-element'; import { ViewTemplate } from '@microsoft/fast-element'; @@ -26,10 +29,12 @@ export interface AsyncElementRenderer extends Omit>; // (undocumented) withDefaultElementRenderers(...renderers: ConstructableElementRenderer[]): void; @@ -61,6 +66,8 @@ export interface ElementRenderer { // (undocumented) connectedCallback(): void; // (undocumented) + disconnectedCallback(): void; + // (undocumented) dispatchEvent(event: Event): boolean; // (undocumented) renderAttributes(): IterableIterator; @@ -103,11 +110,13 @@ export default fastSSR; export type Middleware = (req: any, res: any, next: () => any) => void; // @beta (undocumented) -export type RenderInfo = { - elementRenderers: ConstructableElementRenderer[]; - customElementInstanceStack: ElementRenderer[]; +export interface RenderInfo extends Disposable_2 { + // @deprecated customElementHostStack: ElementRenderer[]; -}; + customElementInstanceStack: ElementRenderer[]; + elementRenderers: ConstructableElementRenderer[]; + renderedCustomElementList: ElementRenderer[]; +} // @beta export const RequestStorage: Readonly<{ @@ -125,21 +134,24 @@ export const RequestStorageManager: Readonly<{ installDOMShim(): void; uninstallDOMShim(): void; installDIContextRequestStrategy(): void; - createStorage(options?: StorageOptions): Map; + createStorage(options?: StorageOptions, req?: any): Map; run(storage: Map, callback: () => T): T; middleware(options?: StorageOptions): Middleware; }>; // @beta export interface SSRConfiguration { - deferHydration?: boolean; + deferHydration?: boolean | ((tagName: string) => boolean); + emitHydratableMarkup?: boolean; renderMode?: "sync" | "async"; + styleRenderer?: StyleRenderer; + tryRecoverFromError?: boolean | ((e: unknown) => void); viewBehaviorFactoryRenderers?: ViewBehaviorFactoryRenderer[]; } // @beta export type StorageOptions = { - createWindow?: () => { + createWindow?: (req: any) => { [key: string]: unknown; }; storage?: Map; @@ -147,14 +159,19 @@ export type StorageOptions = { // @beta export interface StyleRenderer { - render(styles: ComposableStyles): string; + render(styles: Set): string; } +// @public (undocumented) +export const templateCacheController: TemplateCacheController; + // @beta (undocumented) -export interface TemplateRenderer { +export interface TemplateRenderer extends EventEmitter { // (undocumented) createRenderInfo(): RenderInfo; // (undocumented) + readonly emitHydratableMarkup: boolean; + // (undocumented) render(template: ViewTemplate | string, renderInfo?: RenderInfo, source?: unknown, context?: ExecutionContext): IterableIterator; // (undocumented) withDefaultElementRenderers(...renderers: ConstructableElementRenderer[]): void; @@ -169,8 +186,8 @@ export interface ViewBehaviorFactoryRenderer { // Warnings were encountered during analysis: // -// dist/dts/exports.d.ts:41:5 - (ae-forgotten-export) The symbol "SyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts -// dist/dts/exports.d.ts:56:5 - (ae-forgotten-export) The symbol "AsyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts +// dist/dts/exports.d.ts:60:5 - (ae-forgotten-export) The symbol "SyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts +// dist/dts/exports.d.ts:75:5 - (ae-forgotten-export) The symbol "AsyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts // dist/dts/request-storage.d.ts:33:5 - (ae-forgotten-export) The symbol "getItem" needs to be exported by the entry point exports.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/web-components/fast-ssr/server/server.ts b/packages/web-components/fast-ssr/server/server.ts index 510344db9b0..76b0e5e4a32 100644 --- a/packages/web-components/fast-ssr/server/server.ts +++ b/packages/web-components/fast-ssr/server/server.ts @@ -59,6 +59,14 @@ app.get("/fast-style.js", (req: Request, res: Response) => res ) ); +app.get("/fast-style-config.js", (req: Request, res: Response) => + handlePathRequest( + "./dist/esm/styles/fast-style-config.js", + "application/javascript", + req, + res + ) +); app.get("/fast-command-buffer", (req: Request, res: Response) => handlePathRequest( "./src/fast-command-buffer/index.fixture.html", diff --git a/packages/web-components/fast-ssr/src/configure-fast-element.ts b/packages/web-components/fast-ssr/src/configure-fast-element.ts index dc2455990c7..6dbdf407faf 100644 --- a/packages/web-components/fast-ssr/src/configure-fast-element.ts +++ b/packages/web-components/fast-ssr/src/configure-fast-element.ts @@ -1,9 +1,11 @@ import { Compiler, + ElementController, ElementStyles, Updates, ViewBehaviorFactory, } from "@microsoft/fast-element"; +import { SSRElementController } from "./element-controller.js"; import { FASTSSRStyleStrategy } from "./styles/style-strategy.js"; import { SSRView } from "./view.js"; @@ -28,3 +30,5 @@ ElementStyles.setDefaultStrategy(FASTSSRStyleStrategy); // This is required due to the synchronous nature of rendering templates // to a string. Updates.setMode(false); + +ElementController.setStrategy(SSRElementController); diff --git a/packages/web-components/fast-ssr/src/dom-shim.spec.ts b/packages/web-components/fast-ssr/src/dom-shim.spec.ts index 4828199d022..8f5585b24ba 100644 --- a/packages/web-components/fast-ssr/src/dom-shim.spec.ts +++ b/packages/web-components/fast-ssr/src/dom-shim.spec.ts @@ -1,8 +1,7 @@ import "./install-dom-shim.js"; - -import { ElementViewTemplate, FASTElement } from "@microsoft/fast-element"; -import * as Foundation from "@microsoft/fast-foundation"; import { expect, test } from "@playwright/test"; +import * as Foundation from "@microsoft/fast-foundation"; +import { ElementViewTemplate, FASTElement } from "@microsoft/fast-element"; import { createWindow } from "./dom-shim.js"; import fastSSR from "./exports.js"; import { uniqueElementName } from "@microsoft/fast-element/testing.js"; @@ -20,13 +19,15 @@ test.describe("createWindow", () => { test("should create a window with a customElements property that is an instance of the window's CustomElementRegistry constructor", () => { const window = createWindow(); - expect(window.customElements instanceof (window.CustomElementRegistry as any)).toBe(true); + expect( + window.customElements instanceof (window.CustomElementRegistry as any) + ).toBe(true); class MyRegistry {} const windowOverride = createWindow({ CustomElementRegistry: MyRegistry }); expect(windowOverride.customElements instanceof MyRegistry).toBe(true); }); -}) +}); function deriveName(ctor: typeof FASTElement) { const baseName = ctor.name.toLowerCase(); @@ -53,21 +54,30 @@ const componentsAndTemplates: [typeof FASTElement, ElementViewTemplate][] = [ [Foundation.FASTBreadcrumb, Foundation.breadcrumbTemplate()], [Foundation.FASTBreadcrumbItem, Foundation.breadcrumbItemTemplate()], [Foundation.FASTButton, Foundation.buttonTemplate()], - [Foundation.FASTCalendar, Foundation.calendarTemplate({ - dataGrid, - dataGridRow, - dataGridCell - })], + [ + Foundation.FASTCalendar, + Foundation.calendarTemplate({ + dataGrid, + dataGridRow, + dataGridCell, + }), + ], [Foundation.FASTCard, Foundation.cardTemplate()], [Foundation.FASTCheckbox, Foundation.checkboxTemplate()], [Foundation.FASTCombobox, Foundation.comboboxTemplate()], - [Foundation.FASTDataGrid, Foundation.dataGridTemplate({ - dataGridRow - })], + [ + Foundation.FASTDataGrid, + Foundation.dataGridTemplate({ + dataGridRow, + }), + ], [Foundation.FASTDataGridCell, Foundation.dataGridCellTemplate()], - [Foundation.FASTDataGridRow, Foundation.dataGridRowTemplate({ - dataGridCell - })], + [ + Foundation.FASTDataGridRow, + Foundation.dataGridRowTemplate({ + dataGridCell, + }), + ], [Foundation.FASTDialog, Foundation.dialogTemplate()], [Foundation.FASTDisclosure, Foundation.disclosureTemplate()], [Foundation.FASTDivider, Foundation.dividerTemplate()], @@ -78,14 +88,17 @@ const componentsAndTemplates: [typeof FASTElement, ElementViewTemplate][] = [ [Foundation.FASTMenu, Foundation.menuTemplate()], [Foundation.FASTMenuItem, Foundation.menuItemTemplate()], [Foundation.FASTNumberField, Foundation.numberFieldTemplate()], - [Foundation.FASTPicker, Foundation.pickerTemplate({ - anchoredRegion, - pickerList, - pickerListItem, - pickerMenu, - pickerMenuOption, - progressRing - })], + [ + Foundation.FASTPicker, + Foundation.pickerTemplate({ + anchoredRegion, + pickerList, + pickerListItem, + pickerMenu, + pickerMenuOption, + progressRing, + }), + ], [Foundation.FASTPickerList, Foundation.pickerListTemplate()], [Foundation.FASTPickerListItem, Foundation.pickerListItemTemplate()], [Foundation.FASTPickerMenu, Foundation.pickerMenuTemplate()], @@ -105,9 +118,9 @@ const componentsAndTemplates: [typeof FASTElement, ElementViewTemplate][] = [ [Foundation.FASTTextArea, Foundation.textAreaTemplate()], [Foundation.FASTTextField, Foundation.textFieldTemplate()], [Foundation.FASTToolbar, Foundation.toolbarTemplate()], - [Foundation.FASTTooltip, Foundation.tooltipTemplate()], + [Foundation.FASTTooltip,Foundation.tooltipTemplate()], [Foundation.FASTTreeItem, Foundation.treeItemTemplate()], - [Foundation.FASTTreeView, Foundation.treeViewTemplate()] + [Foundation.FASTTreeView, Foundation.treeViewTemplate()], ]; test.describe("The DOM shim", () => { @@ -151,24 +164,24 @@ test.describe("The DOM shim", () => { rule.style.removeProperty("--test"); expect(rule.cssText).toBe(":host { }"); - }) - }) + }); + }); }); test.describe("has a matchMedia method", () => { test("that can returns the MediaQueryList supplied to createWindow", () => { - class MyMediaQueryList {}; + class MyMediaQueryList {} - const win: any = createWindow({MediaQueryList: MyMediaQueryList}); + const win: any = createWindow({ MediaQueryList: MyMediaQueryList }); const list = win.matchMedia(); expect(list).toBeInstanceOf(MyMediaQueryList); - }) + }); }); test.describe("DOMTokenList", () => { class TestElement extends FASTElement {} - TestElement.define({ name: uniqueElementName() }) + TestElement.define({ name: uniqueElementName() }); test("adds a token", () => { const element = new TestElement(); @@ -177,10 +190,9 @@ test.describe("The DOM shim", () => { cList.toggle("c1"); expect(cList.contains("c1"), "adds a token that is not present").toBeTruthy(); - expect( - cList.toggle("c2"), - "returns true when token is added" - ).toStrictEqual(true); + expect(cList.toggle("c2"), "returns true when token is added").toStrictEqual( + true + ); }); test("removes a token", () => { @@ -260,8 +272,11 @@ test.describe("The DOM shim", () => { const cList = element.classList; cList.remove("ho"); - expect(!cList.contains("ho"), "should remove all instances of 'ho'").toBeTruthy(); + expect( + !cList.contains("ho"), + "should remove all instances of 'ho'" + ).toBeTruthy(); expect(element.className).toStrictEqual(""); }); }); -}) +}); diff --git a/packages/web-components/fast-ssr/src/dom-shim.ts b/packages/web-components/fast-ssr/src/dom-shim.ts index bfa7bb4d140..945853b50a6 100644 --- a/packages/web-components/fast-ssr/src/dom-shim.ts +++ b/packages/web-components/fast-ssr/src/dom-shim.ts @@ -368,6 +368,9 @@ export class MediaQueryList { /** No-op */ addEventListener() {} + /** No-op */ + removeEventListener() {} + /** Always false */ matches = false; } diff --git a/packages/web-components/fast-ssr/src/element-controller.ts b/packages/web-components/fast-ssr/src/element-controller.ts new file mode 100644 index 00000000000..b12dd9390ac --- /dev/null +++ b/packages/web-components/fast-ssr/src/element-controller.ts @@ -0,0 +1,14 @@ +import { ElementController } from "@microsoft/fast-element"; + +export class SSRElementController extends ElementController { + disconnect(): void { + super.disconnect(); + + // remove all behaviors to avoid memory leak + if (this.behaviors !== null) { + for (const [behavior] of this.behaviors) { + this.removeBehavior(behavior, true); + } + } + } +} diff --git a/packages/web-components/fast-ssr/src/element-renderer/elemenent-renderer.spec.ts b/packages/web-components/fast-ssr/src/element-renderer/element-renderer.spec.ts similarity index 59% rename from packages/web-components/fast-ssr/src/element-renderer/elemenent-renderer.spec.ts rename to packages/web-components/fast-ssr/src/element-renderer/element-renderer.spec.ts index 525bb12d25f..a33ae0b0e54 100644 --- a/packages/web-components/fast-ssr/src/element-renderer/elemenent-renderer.spec.ts +++ b/packages/web-components/fast-ssr/src/element-renderer/element-renderer.spec.ts @@ -1,11 +1,12 @@ import "../install-dom-shim.js"; -import { FASTElement, customElement, css, html, attr, observable, when } from "@microsoft/fast-element"; + +import { attr, css, customElement, FASTElement, html, observable, when } from "@microsoft/fast-element"; +import { PendingTaskEvent } from "@microsoft/fast-element/pending-task.js"; +import { uniqueElementName } from "@microsoft/fast-element/testing.js"; import { expect, test } from '@playwright/test'; -import { SyncFASTElementRenderer } from "./fast-element-renderer.js"; import fastSSR from "../exports.js"; import { consolidate, consolidateAsync } from "../test-utilities/consolidate.js"; -import { uniqueElementName } from "@microsoft/fast-element/testing.js"; -import { PendingTaskEvent } from "@microsoft/fast-element/pending-task.js"; +import { SyncFASTElementRenderer } from "./fast-element-renderer.js"; @customElement({ name: "bare-element", @@ -25,6 +26,86 @@ export class StyledElement extends FASTElement {} }) export class HostBindingElement extends FASTElement {} +test.describe("FallbackRenderer", () => { + // Define custom element that is not a FAST elmeent. + const name = uniqueElementName(); + customElements.define(name, class extends HTMLElement{}); + test.describe("rendering an element with attributes", () => { + test("should not render the attribute when binding evaluates null", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + <${html.partial(name)} attr="${x => null}"> + `)); + expect(result).toBe(` + <${name} > + `); + }); + + test("should not render the attribute when the binding evaluates undefined", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + <${html.partial(name)} attr="${x => undefined}"> + `)); + expect(result).toBe(` + <${name} > + `); + }); + + test("should render a boolean attribute with the values of true or false", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + <${html.partial(name)} ?attr="${x => true}"> + <${html.partial(name)} ?attr="${x => false}"> + `)); + expect(result).toBe(` + <${name} attr> + <${name} > + `); + }); + + test("should render a non-boolean attribute with the values of true or false", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + <${html.partial(name)} aria-expanded="${x => true}"> + <${html.partial(name)} aria-expanded="${x => false}"> + `)); + expect(result).toBe(` + <${name} aria-expanded="true"> + <${name} aria-expanded="false"> + `); + }); + + test("should render an attribute with the value of a number", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + <${html.partial(name)} number="${x => 12}"> + <${html.partial(name)} number="${x => NaN}"> + `)); + expect(result).toBe(` + <${name} number="12"> + <${name} number="NaN"> + `); + }); + + test("should render an attribute with a string value", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + <${html.partial(name)} attr="${x => 'my-str-value'}"> + `)); + expect(result).toBe(` + <${name} attr="my-str-value"> + `); + }); + + test("should throw error when rendering an attribute with an object value", () => { + const { templateRenderer } = fastSSR(); + + expect(() => { + consolidate(templateRenderer.render(html`<${html.partial(name)} attr="${x => ({ key: 'my-value' })}">`)); + }).toThrowError() + }); + }); +}); test.describe("FASTElementRenderer", () => { test.describe("should have a 'matchesClass' method", () => { @@ -70,24 +151,36 @@ test.describe("FASTElementRenderer", () => { test(`should render stylesheets as 'style' elements by default`, () => { const { templateRenderer } = fastSSR(); const result = consolidate(templateRenderer.render(html``)); - expect(result).toBe(""); + expect(result).toBe(``); }); test.skip(`should render stylesheets as 'fast-style' elements when configured`, () => { const { templateRenderer } = fastSSR(/* Replace w/ configuration when fast-style work is complete{useFASTStyle: true}*/); const result = consolidate(templateRenderer.render(html``)); - expect(result).toBe(``); + expect(result).toBe(``); }); }); - test("should render attributes on the root of a template element to the host element", () => { - const { templateRenderer } = fastSSR(); - const result = consolidate(templateRenderer.render(html` - - `)); - expect(result).toBe(` - - `); - }); + test.describe("with host bindings", () => { + test("should render attributes on the root of a template element to the host element", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + + `)); + expect(result).toBe(` + + `); + }); + + test("should not evaluate event bindings on the host", () => { + const { templateRenderer } = fastSSR(); + const name = uniqueElementName(); + FASTElement.define({name , template: html``}); + + expect(() => { + consolidate(templateRenderer.render(`<${name}>`)); + }).not.toThrow() + }); + }) test.describe("rendering an element with attributes", () => { test("should not render the attribute when binding evaluates null", () => { @@ -96,7 +189,7 @@ test.describe("FASTElementRenderer", () => { `)); expect(result).toBe(` - + `); }); test("should not render the attribute when the binding evaluates undefined", () => { @@ -105,7 +198,7 @@ test.describe("FASTElementRenderer", () => { `)); expect(result).toBe(` - + `); }); @@ -116,8 +209,8 @@ test.describe("FASTElementRenderer", () => { `)); expect(result).toBe(` - - + + `); }); @@ -128,8 +221,20 @@ test.describe("FASTElementRenderer", () => { `)); expect(result).toBe(` - - + + + `); + }); + + test("should render an attribute with the value of a number", () => { + const { templateRenderer } = fastSSR(); + const result = consolidate(templateRenderer.render(html` + + + `)); + expect(result).toBe(` + + `); }); @@ -139,7 +244,7 @@ test.describe("FASTElementRenderer", () => { `)); expect(result).toBe(` - + `); }); @@ -217,13 +322,13 @@ test.describe("FASTElementRenderer", () => { test("An element dispatching an event should get it's own handler fired", () => { const { templateRenderer } = fastSSR(); const result = consolidate(templateRenderer.render(html`` )); - expect(result).toBe(``) + expect(result).toBe(``) }); test("An ancestor with a handler should get it's handler invoked if the event bubbles", () => { const { templateRenderer } = fastSSR(); const result = consolidate(templateRenderer.render(html``)); - expect(result).toBe("") + expect(result).toBe(``) }); test("Should bubble events to the document", () => { document.addEventListener("test-event", (e) => { @@ -233,7 +338,7 @@ test.describe("FASTElementRenderer", () => { const result = consolidate(templateRenderer.render(html``)); - expect(result).toBe(``); + expect(result).toBe(``); }); test("Should bubble events to the window", () => { window.addEventListener("test-event", (e) => { @@ -242,23 +347,23 @@ test.describe("FASTElementRenderer", () => { const { templateRenderer } = fastSSR(); const result = consolidate(templateRenderer.render(html``)); - expect(result).toBe(``); + expect(result).toBe(``); }); test("Should not bubble an event that invokes event.stopImmediatePropagation()", () => { const { templateRenderer } = fastSSR(); const result = consolidate(templateRenderer.render(html``)); - expect(result).toBe(``) + expect(result).toBe(``) }); test("Should not bubble an event that invokes event.stopPropagation()", () => { const { templateRenderer } = fastSSR(); const result = consolidate(templateRenderer.render(html``)); - expect(result).toBe(``) + expect(result).toBe(``) }); }); - test.describe("rendering asynchronously", () => { + test.describe("rendering asynchronously", () => { test("should support attribute mutation for the element as a result of PendingTask events", async () => { const name = uniqueElementName(); @customElement({ @@ -279,11 +384,11 @@ test.describe("FASTElementRenderer", () => { const template = html`<${html.partial(name)}>`; const { templateRenderer } = fastSSR({renderMode: "async"}); - expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved>`) + expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved>`) }); - test("should render elements that have rejected PendingTaskEvents", async () => { + test("should throw when the element catches rejected PendingTaskEvents", async () => { const name = uniqueElementName(); @customElement({ name, @@ -294,7 +399,7 @@ test.describe("FASTElementRenderer", () => { this.dispatchEvent(new PendingTaskEvent(new Promise((resolve, reject) => { window.setTimeout(() => { this.setAttribute("async-reject", ""); - reject(); + reject(new Error()); }, 20); }))); } @@ -302,8 +407,12 @@ test.describe("FASTElementRenderer", () => { const template = html`<${html.partial(name)}>`; const { templateRenderer } = fastSSR({renderMode: "async"}); - - expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-reject>`) + try { + await consolidateAsync(templateRenderer.render(template)) + expect("didn't error").toBe("errored"); + } catch(e) { + expect("errored").toBe("errored"); + } }); test("should await multiple PendingTaskEvents", async () => { const name = uniqueElementName(); @@ -331,7 +440,7 @@ test.describe("FASTElementRenderer", () => { const template = html`<${html.partial(name)}>`; const { templateRenderer } = fastSSR({renderMode: "async"}); - expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved-one async-resolved-two>`) + expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved-one async-resolved-two>`) }); test("should render template content only displayed after PendingTaskEvent is resolved", async () => { const name = uniqueElementName(); @@ -356,7 +465,7 @@ test.describe("FASTElementRenderer", () => { const template = html`<${html.partial(name)}>`; const { templateRenderer } = fastSSR({renderMode: "async"}); - expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name}>`) + expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name}>`) }); test("should support nested async rendering scenarios", async () => { const name = uniqueElementName(); @@ -381,7 +490,61 @@ test.describe("FASTElementRenderer", () => { const template = html`<${html.partial(name)}><${html.partial(name)}>`; const { templateRenderer } = fastSSR({renderMode: "async"}); - expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved><${name} async-resolved>`) + expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved><${name} async-resolved>`) + }); + + test("should support asyncronously calling FASTElement.connectedCallback", async () => { + const name = uniqueElementName(); + @customElement({ + name, + template: html`

attr value: ${x => x.value}

` + }) + class MyElement extends FASTElement { + @attr + value: string = ""; + + connectedCallback(): void { + this.asyncConnectedCallback(); + } + + async asyncConnectedCallback() { + this.dispatchEvent(new PendingTaskEvent(new Promise((resolve) => { + window.setTimeout(() => { + super.connectedCallback(); + resolve(); + }, 20); + }))); + } + } + + + const { templateRenderer } = fastSSR({renderMode: "async"}); + const template = `<${name} value="hello-world">`; + expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} value="hello-world">`) + }) + }); + + test("should match FAST elements that extend base-classes created from `FASTElement.from()`", () => { + const BaseClass = FASTElement.from(HTMLElement); + class MyElement extends BaseClass {} + + const name = uniqueElementName(); + FASTElement.define(MyElement, name); + + const { ElementRenderer } = fastSSR(); + + expect(ElementRenderer.matchesClass(MyElement, name, new Map())).toBe(true) + }) + + test.describe("with hydration markup enabled", () => { + test("should emit the 'needs-hydration' attribute from `renderAttributes()`", () => { + const { ElementRenderer, templateRenderer } = fastSSR({ emitHydratableMarkup: true}); + const name = uniqueElementName(); + + FASTElement.define(name); + const renderer = new ElementRenderer(name, templateRenderer.createRenderInfo()); + + expect(consolidate(renderer.renderAttributes()).split(" ")).toContain("needs-hydration"); }); }) }); diff --git a/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts b/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts index c03528d469a..d8946a34406 100644 --- a/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts +++ b/packages/web-components/fast-ssr/src/element-renderer/element-renderer.ts @@ -2,32 +2,13 @@ * This file was ported from {@link https://github.com/lit/lit/tree/main/packages/labs/ssr}. * Please see {@link ../ACKNOWLEDGEMENTS.md} */ -import { observable } from "@microsoft/fast-element"; +import { DOM, observable } from "@microsoft/fast-element"; import { RenderInfo } from "../render-info.js"; import { escapeHtml } from "../escape-html.js"; import { HTMLElement as ShimHTMLElement } from "../dom-shim.js"; import { shouldBubble } from "../event-utilities.js"; import { AttributesMap, ElementRenderer } from "./interfaces.js"; -export const getElementRenderer = ( - renderInfo: RenderInfo, - tagName: string, - ceClass: typeof HTMLElement | undefined = customElements.get(tagName), - attributes: AttributesMap = new Map() -): ElementRenderer => { - if (ceClass === undefined) { - console.warn(`Custom element ${tagName} was not registered.`); - } else { - for (const renderer of renderInfo.elementRenderers) { - if (renderer.matchesClass(ceClass, tagName, attributes)) { - return new renderer(tagName, renderInfo); - } - } - } - - return new FallbackRenderer(tagName, renderInfo); -}; - /** * @beta */ @@ -35,19 +16,22 @@ export abstract class DefaultElementRenderer implements Omit { private parent: ElementRenderer | null = null; + private restoreElementDispatchEvent: (() => void) | null = null; @observable - abstract readonly element?: HTMLElement; + abstract element?: HTMLElement; elementChanged() { + this.restoreElementDispatchEvent?.(); if (this.element) { - const dispatch = this.element.dispatchEvent; - Reflect.defineProperty(this.element, "dispatchEvent", { + const element = this.element; + const dispatch = element.dispatchEvent; + Reflect.defineProperty(element, "dispatchEvent", { value: (event: Event) => { - let canceled = dispatch.call(this.element, event); + let canceled = dispatch.call(element, event); if (shouldBubble(event)) { if (this.parent) { canceled = this.parent.dispatchEvent(event); - } else if (this.element instanceof ShimHTMLElement) { + } else if (element instanceof ShimHTMLElement) { // Only emit on document if the the element is the DOM shim's element. // Otherwise, the installed DOM should implement it's own behavior for bubbling // an event from an element to the document and window. @@ -58,6 +42,12 @@ export abstract class DefaultElementRenderer return canceled; }, }); + this.restoreElementDispatchEvent = () => { + Reflect.defineProperty(element, "dispatchEvent", { + value: dispatch, + }); + this.restoreElementDispatchEvent = null; + }; } } @@ -76,14 +66,16 @@ export abstract class DefaultElementRenderer return false; } - constructor( - public readonly tagName: string, - private readonly renderInfo?: RenderInfo - ) { + constructor(public readonly tagName: string, renderInfo?: RenderInfo) { this.parent = renderInfo?.customElementInstanceStack.at(-1) || null; } - abstract connectedCallback(): void; + connectedCallback() {} + disconnectedCallback() { + this.restoreElementDispatchEvent?.(); + this.parent = null; + delete this.element; + } abstract attributeChangedCallback( name: string, prev: string | null, @@ -109,35 +101,62 @@ export abstract class DefaultElementRenderer public setAttribute(name: string, value: string) { if (this.element) { const prev = this.element.getAttribute(name); - this.element.setAttribute(name, value); - this.attributeChangedCallback(name, prev, value); + if (value !== prev) { + DOM.setAttribute(this.element, name, value); + this.attributeChangedCallback(name, prev, value); + } } } } +export function renderAttribute(name: string, value: unknown, tagName?: string) { + if (value === "" || value === undefined || value === null) { + return ` ${name}`; + } else if (typeof value === "string") { + return ` ${name}="${escapeHtml(value)}"`; + } else if (typeof value === "boolean" || typeof value === "number") { + return ` ${name}="${value}"`; + } else { + throw new Error(`Cannot assign attribute '${name}' for element ${tagName}.`); + } +} + /** * An ElementRenderer used as a fallback in the case where a custom element is * either unregistered or has no other matching renderer. */ -class FallbackRenderer extends DefaultElementRenderer implements ElementRenderer { +export class FallbackRenderer extends DefaultElementRenderer implements ElementRenderer { public element?: HTMLElement | undefined; + + /** + * When true, instructs the ElementRenderer to yield the `defer-hydration` attribute for + * rendered elements. + */ + public deferHydration = false; + private readonly _attributes: { [name: string]: string } = {}; public setAttribute(name: string, value: string) { - this._attributes[name] = value; + if (value === undefined || value === null) { + this.removeAttribute(name); + } else { + this._attributes[name] = value; + } + } + + public removeAttribute(name: string) { + delete this._attributes[name]; } public *renderAttributes(): IterableIterator { + if (this.deferHydration) { + yield " defer-hydration"; + } for (const [name, value] of Object.entries(this._attributes)) { - if (value === "" || value === undefined || value === null) { - yield ` ${name}`; - } else { - yield ` ${name}="${escapeHtml(value)}"`; - } + yield renderAttribute(name, value, this.element?.tagName); } } - connectedCallback() {} attributeChangedCallback() {} /* eslint-disable-next-line */ *renderShadow() {} diff --git a/packages/web-components/fast-ssr/src/element-renderer/fast-element-renderer.ts b/packages/web-components/fast-ssr/src/element-renderer/fast-element-renderer.ts index 8371bf3a85d..84581fa3321 100644 --- a/packages/web-components/fast-ssr/src/element-renderer/fast-element-renderer.ts +++ b/packages/web-components/fast-ssr/src/element-renderer/fast-element-renderer.ts @@ -1,14 +1,33 @@ -import { DOM, DOMAspect, ExecutionContext, FASTElement } from "@microsoft/fast-element"; +import { + DOM, + DOMAspect, + ExecutionContext, + FASTElement, + FASTElementDefinition, + HostController, + Observable, + observable, +} from "@microsoft/fast-element"; import { PendingTaskEvent } from "@microsoft/fast-element/pending-task.js"; -import { escapeHtml } from "../escape-html.js"; import { RenderInfo } from "../render-info.js"; import { StyleRenderer } from "../styles/style-renderer.js"; import { FASTSSRStyleStrategy } from "../styles/style-strategy.js"; import { DefaultTemplateRenderer } from "../template-renderer/template-renderer.js"; import { SSRView } from "../view.js"; -import { DefaultElementRenderer } from "./element-renderer.js"; +import { DefaultElementRenderer, renderAttribute } from "./element-renderer.js"; import { AsyncElementRenderer, AttributesMap, ElementRenderer } from "./interfaces.js"; +/** + * Sinks to apply aspect operations to an element + */ +const sinks: Record void> = { + [DOMAspect.property]: (el: any, name: string, value: any) => { + el[name] = value; + }, + [DOMAspect.attribute]: DOM.setAttribute, + [DOMAspect.booleanAttribute]: DOM.setBooleanAttribute, +}; + /** * An {@link ElementRenderer} implementation designed to render components * built with FAST. @@ -21,21 +40,37 @@ abstract class FASTElementRenderer extends DefaultElementRenderer { public readonly element!: FASTElement; /** + * The custom element constructor + */ + public readonly ctor: CustomElementConstructor; + + /** + * A function that decies if given tagName should defer hydration. * When true, instructs the ElementRenderer to yield the `defer-hydration` attribute for * rendered elements. */ - protected abstract deferHydration: boolean; + protected abstract deferHydration: (tagName: string) => boolean; + + protected needsHydration: boolean = false; /** * The template renderer to use when rendering a component template */ + @observable protected abstract templateRenderer: DefaultTemplateRenderer; + templateRendererChanged() { + if (this.templateRenderer) { + this.needsHydration = this.templateRenderer.emitHydratableMarkup; + } + } /** * Responsible for rendering stylesheets */ protected abstract styleRenderer: StyleRenderer; + protected attributes: AttributesMap = new Map(); + public static matchesClass( ctor: typeof HTMLElement, tagName: string, @@ -48,40 +83,76 @@ abstract class FASTElementRenderer extends DefaultElementRenderer { * Indicate to the {@link FASTElementRenderer} that the instance should execute DOM connection behavior. */ public connectedCallback(): void { - this.element.connectedCallback(); - const view = this.element.$fastController.view; - - if (view && SSRView.isSSRView(view)) { - if (view.hostStaticAttributes) { - view.hostStaticAttributes.forEach((value, key) => { - this.element.setAttribute(key, value); - }); + super.connectedCallback(); + Observable.getNotifier(this.element.$fastController).subscribe( + this, + "isConnected" + ); + + try { + this.element.connectedCallback(); + } catch (e) { + const { tryRecoverFromErrors } = this.templateRenderer; + if (tryRecoverFromErrors) { + this.disableTemplateRendering(); + + if (typeof tryRecoverFromErrors === "function") { + tryRecoverFromErrors(e); + } + } else { + throw e; } + } + } - if (view.hostDynamicAttributes) { - for (const attr of view.hostDynamicAttributes) { - const result = attr.dataBinding.evaluate( - this.element, - ExecutionContext.default - ); - - const { target } = attr; - switch (attr.aspect) { - case DOMAspect.property: - (this.element as any)[target] = result; - break; - case DOMAspect.attribute: - DOM.setAttribute(this.element, target, result); - break; - case DOMAspect.booleanAttribute: - DOM.setBooleanAttribute(this.element, target, result); - break; + public disconnectedCallback(): void { + super.disconnectedCallback(); + Observable.getNotifier(this.element.$fastController).unsubscribe( + this, + "isConnected" + ); + this.element.disconnectedCallback(); + } + + public handleChange(source: HostController, property: "isConnected") { + if (this.element.$fastController.isConnected) { + const view = this.element.$fastController.view; + + if (view && SSRView.isSSRView(view)) { + if (view.hostStaticAttributes) { + view.hostStaticAttributes.forEach((value, key) => { + this.element.setAttribute(key, value); + }); + } + + if (view.hostDynamicAttributes) { + for (const attr of view.hostDynamicAttributes) { + const aspect = attr.factory.aspectType; + if (aspect in sinks && attr.factory.dataBinding) { + sinks[aspect]( + this.element, + attr.factory.targetAspect, + attr.factory.dataBinding.evaluate( + this.element, + ExecutionContext.default + ) + ); + } } } } } } + /** + * Disables the rendering of the component's template. + */ + protected disableTemplateRendering() { + this.renderShadow = noOpRenderShadow; + this.yieldAttributes = yieldContextualAttributes; + this.needsHydration = false; + } + /** * Indicate to the {@link FASTElementRenderer} that an attribute has been changed. * @param name - The name of the changed attribute @@ -96,6 +167,11 @@ abstract class FASTElementRenderer extends DefaultElementRenderer { this.element.attributeChangedCallback(name, old, value); } + public setAttribute(name: string, value: string): void { + super.setAttribute(name, value); + this.attributes.set(name, value); + } + /** * Constructs a new {@link FASTElementRenderer}. * @param tagName - the tag-name of the element to create. @@ -106,6 +182,7 @@ abstract class FASTElementRenderer extends DefaultElementRenderer { const ctor = customElements.get(this.tagName); if (ctor) { + this.ctor = ctor; this.element = new ctor() as FASTElement; (this.element as any).tagName = tagName; } else { @@ -114,6 +191,25 @@ abstract class FASTElementRenderer extends DefaultElementRenderer { ); } } + + /** + * Yield attributes assigned to the elmeent + * + */ + protected *yieldAttributes() { + const { attributes } = this.element; + for ( + let i = 0, name, value; + i < attributes.length && ({ name, value } = attributes[i]); + i++ + ) { + yield renderAttribute(name, value, this.element.tagName); + } + } + + abstract renderShadow: + | ((renderInfo: RenderInfo) => IterableIterator) + | ((renderInfo: RenderInfo) => IterableIterator>); } export abstract class SyncFASTElementRenderer @@ -137,23 +233,34 @@ export abstract class AsyncFASTElementRenderer ): IterableIterator> { if (this.element !== undefined) { if (this.awaiting.size) { - yield this.pauseRendering().then(() => ""); + yield this.pauseRendering() + .then(() => "") + .catch(reason => { + throw reason; + }); } yield* renderAttributesSync.call(this); } } - renderShadow = renderShadow as ( - renderInfo: RenderInfo - ) => IterableIterator>; + renderShadow = renderShadow; private async pauseRendering() { for (const awaiting of this.awaiting) { try { await awaiting; } catch (e) { - // Await will throw if the Promise is rejected. In that case, - // SSR should just continue rendering + const { tryRecoverFromErrors } = this.templateRenderer; + + if (tryRecoverFromErrors) { + this.disableTemplateRendering(); + + if (typeof tryRecoverFromErrors === "function") { + tryRecoverFromErrors(e); + } + } else { + throw e; + } } } @@ -170,29 +277,14 @@ export abstract class AsyncFASTElementRenderer } function* renderAttributesSync(this: FASTElementRenderer): IterableIterator { - if (this.element !== undefined) { - const { attributes } = this.element; - for ( - let i = 0, name, value; - i < attributes.length && ({ name, value } = attributes[i]); - i++ - ) { - if (value === "" || value === undefined || value === null) { - yield ` ${name}`; - } else if (typeof value === "string") { - yield ` ${name}="${escapeHtml(value)}"`; - } else if (typeof value === "boolean") { - yield ` ${name}="${value}"`; - } else { - throw new Error( - `Cannot assign attribute '${name}' for element ${this.element.tagName}.` - ); - } - } + yield* this.yieldAttributes(); - if (this.deferHydration) { - yield " defer-hydration"; - } + if (this.deferHydration(this.tagName)) { + yield " defer-hydration"; + } + + if (this.needsHydration) { + yield " needs-hydration"; } } @@ -200,13 +292,22 @@ function* renderShadow( this: FASTElementRenderer, renderInfo: RenderInfo ): IterableIterator { - const view = this.element.$fastController.view; + const view = this.element.$fastController.view as unknown as SSRView; const styles = FASTSSRStyleStrategy.getStylesFor(this.element); + const elementDefinition = FASTElementDefinition.getByType(this.ctor); + const yieldDSD = elementDefinition?.shadowOptions !== undefined; + const yieldBoundaryMarker = + elementDefinition && elementDefinition.shadowOptions === undefined && view; + + if (yieldDSD) { + yield '"; + } else if (yieldBoundaryMarker) { + yield ``; + } +} + +// eslint-disable-next-line +function* noOpRenderShadow() {} +function* yieldContextualAttributes(this: FASTElementRenderer) { + const { attributes } = this; + for (const [key, value] of attributes) { + yield renderAttribute(key, value, this.element.tagName); + } } diff --git a/packages/web-components/fast-ssr/src/element-renderer/interfaces.ts b/packages/web-components/fast-ssr/src/element-renderer/interfaces.ts index 991ddfead7c..0b911127fae 100644 --- a/packages/web-components/fast-ssr/src/element-renderer/interfaces.ts +++ b/packages/web-components/fast-ssr/src/element-renderer/interfaces.ts @@ -7,6 +7,7 @@ import { RenderInfo } from "../render-info.js"; export interface ElementRenderer { readonly tagName: string; connectedCallback(): void; + disconnectedCallback(): void; attributeChangedCallback( name: string, prev: string | null, diff --git a/packages/web-components/fast-ssr/src/exports.spec.ts b/packages/web-components/fast-ssr/src/exports.spec.ts index 10d3285470b..4606bbcfc55 100644 --- a/packages/web-components/fast-ssr/src/exports.spec.ts +++ b/packages/web-components/fast-ssr/src/exports.spec.ts @@ -1,16 +1,15 @@ import "./install-dom-shim.js"; -import { html, RefDirective, ref } from "@microsoft/fast-element"; + +import { FASTElement, html, HTMLDirective, ref, RefDirective, StatelessAttachedAttributeDirective, ViewController } from "@microsoft/fast-element"; +import { uniqueElementName } from "@microsoft/fast-element/testing.js"; +import { expect, test } from "@playwright/test"; import fastSSR from "./exports.js"; import { ViewBehaviorFactoryRenderer } from "./template-renderer/directives.js"; -import { test, expect } from "@playwright/test"; -import { uniqueElementName } from "@microsoft/fast-element/testing.js"; -import { FASTElement, HTMLDirective, StatelessAttachedAttributeDirective, ViewBehaviorFactory, ViewController } from "@microsoft/fast-element"; import { consolidate } from "./test-utilities/consolidate.js"; - test.describe("fastSSR default export", () => { test("should return a TemplateRenderer configured to create a RenderInfo object using the returned ElementRenderer", () => { - const { templateRenderer, ElementRenderer } = fastSSR(); + const { templateRenderer, ElementRenderer } = fastSSR(); expect(templateRenderer.createRenderInfo().elementRenderers.includes(ElementRenderer)).toBe(true) }) @@ -19,21 +18,27 @@ test.describe("fastSSR default export", () => { const name = uniqueElementName(); FASTElement.define(name); - expect(consolidate(templateRenderer.render(`<${name}>`))).toBe(`<${name}>`) + expect(consolidate(templateRenderer.render(`<${name}>`))).toBe( + `<${name}>` + ); }); test("should render FAST elements with the `defer-hydration` attribute when deferHydration is configured to be true", () => { - const { templateRenderer } = fastSSR({deferHydration: true}); + const { templateRenderer } = fastSSR({ deferHydration: true }); const name = uniqueElementName(); FASTElement.define(name); - expect(consolidate(templateRenderer.render(`<${name}>`))).toBe(`<${name} defer-hydration>`) + expect(consolidate(templateRenderer.render(`<${name}>`))).toBe( + `<${name} defer-hydration>` + ) }); test("should not render FAST elements with the `defer-hydration` attribute when deferHydration is configured to be false", () => { - const { templateRenderer } = fastSSR({deferHydration: false}); + const { templateRenderer } = fastSSR({ deferHydration: false }); const name = uniqueElementName(); FASTElement.define(name); - expect(consolidate(templateRenderer.render(`<${name}>`))).toBe(`<${name}>`) + expect(consolidate(templateRenderer.render(`<${name}>`))).toBe( + `<${name}>` + ) }); test("should render a custom directive using a registered ViewBehaviorFactoryRenderer", () => { @@ -44,7 +49,7 @@ test.describe("fastSSR default export", () => { } } - HTMLDirective.define(Directive) + HTMLDirective.define(Directive); const renderer: ViewBehaviorFactoryRenderer = { matcher: Directive, diff --git a/packages/web-components/fast-ssr/src/exports.ts b/packages/web-components/fast-ssr/src/exports.ts index 48f864521c2..990a42d6740 100644 --- a/packages/web-components/fast-ssr/src/exports.ts +++ b/packages/web-components/fast-ssr/src/exports.ts @@ -41,6 +41,7 @@ export interface SSRConfiguration { /** * Configures the renderer to yield the `defer-hydration` attribute during element rendering. + * Can be a boolean or a function that receives tagName and returns a boolean. * The `defer-hydration` attribute can be used to prevent immediate hydration of the element * by fast-element by importing hydration support in the client bundle. * @@ -51,12 +52,33 @@ export interface SSRConfiguration { * import "@microsoft/fast-element/install-element-hydration"; * ``` */ - deferHydration?: boolean; + deferHydration?: boolean | ((tagName: string) => boolean); + + /** + * Configures the renderer to emit markup that allows fast-element to hydrate elements. + * + * Defaults to `false` + */ + emitHydratableMarkup?: boolean; /** * Renderers for author-defined ViewBehaviorFactories. */ viewBehaviorFactoryRenderers?: ViewBehaviorFactoryRenderer[]; + + /** + * When true or when a error handler is provided, the renderer will + * attempt to recover from errors that occur at specific points, + * during rendering, including: + * + * connectedCallback(): No declarative shadow-dom will be emitted for the element + */ + tryRecoverFromError?: boolean | ((e: unknown) => void); + + /** + * Optional custom style renderer instance, used to override StyleElementStyleRenderer + */ + styleRenderer?: StyleRenderer; } /** @beta */ @@ -95,14 +117,23 @@ function fastSSR(config: SSRConfiguration & Record<"renderMode", "async">): { * @beta */ function fastSSR(config?: SSRConfiguration): any { - config = { + const rConfig: Required = { renderMode: "sync", deferHydration: false, + emitHydratableMarkup: false, + tryRecoverFromError: false, ...config, } as Required; const templateRenderer = new DefaultTemplateRenderer(); - - const elementRenderer = class extends (config.renderMode !== "async" + const deferHydration = + typeof rConfig.deferHydration === "function" + ? rConfig.deferHydration + : () => !!rConfig?.deferHydration; + templateRenderer.deferHydration = deferHydration; + templateRenderer.emitHydratableMarkup = !!rConfig.emitHydratableMarkup; + templateRenderer.tryRecoverFromErrors = rConfig.tryRecoverFromError; + + const elementRenderer = class extends (rConfig.renderMode !== "async" ? SyncFASTElementRenderer : AsyncFASTElementRenderer) { static #disabledConstructors = new Set(); @@ -112,9 +143,7 @@ function fastSSR(config?: SSRConfiguration): any { tagName: string, attributes: AttributesMap ): boolean { - const canRender = ctor.prototype instanceof FASTElement; - - if (!canRender) { + if (FASTElementDefinition.getByType(ctor) === undefined) { return false; } @@ -133,8 +162,9 @@ function fastSSR(config?: SSRConfiguration): any { } } protected templateRenderer: DefaultTemplateRenderer = templateRenderer; - protected styleRenderer = new StyleElementStyleRenderer(); - protected deferHydration = config?.deferHydration; + protected styleRenderer = + rConfig.styleRenderer || new StyleElementStyleRenderer(); + protected deferHydration = deferHydration; }; templateRenderer.withDefaultElementRenderers( @@ -148,9 +178,9 @@ function fastSSR(config?: SSRConfiguration): any { // Add any author-defined ViewBehaviorFactories. This order allows overriding // out-of-box renderers. - if (Array.isArray(config.viewBehaviorFactoryRenderers)) { + if (Array.isArray(rConfig.viewBehaviorFactoryRenderers)) { templateRenderer.withViewBehaviorFactoryRenderers( - ...config.viewBehaviorFactoryRenderers + ...rConfig.viewBehaviorFactoryRenderers ); } @@ -163,6 +193,7 @@ function fastSSR(config?: SSRConfiguration): any { export default fastSSR; export * from "./declarative-shadow-dom-polyfill.js"; export * from "./request-storage.js"; +export { templateCacheController } from "./template-parser/template-parser.js"; export type { ElementRenderer, AsyncElementRenderer, diff --git a/packages/web-components/fast-ssr/src/fast-command-buffer/index.fixture.html b/packages/web-components/fast-ssr/src/fast-command-buffer/index.fixture.html index 1a0ca40c353..9daf46bfab9 100644 --- a/packages/web-components/fast-ssr/src/fast-command-buffer/index.fixture.html +++ b/packages/web-components/fast-ssr/src/fast-command-buffer/index.fixture.html @@ -141,7 +141,7 @@ border-radius: calc(var(--control-corner-radius) * 1px); box-shadow: 0 0 calc((var(--elevation) * 0.225px) + 2px) rgba(0, 0, 0, calc(0.11 * (2 - var(--background-luminance, 1)))), 0 calc(var(--elevation) * 0.4px) calc((var(--elevation) * 0.9px)) rgba(0, 0, 0, calc(0.13 * (2 - var(--background-luminance, 1))));}" > - +
- +
{ + class TestRenderer extends FallbackRenderer { + static matchesClass(ctor: { new(): HTMLElement; prototype: HTMLElement; }, tagName: string, attributes: AttributesMap): boolean { + return true; + } + public connected = false; + + connectedCallback() { + super.connectedCallback(); + this.connected = true; + } + disconnectedCallback() { + super.disconnectedCallback(); + this.connected = false; + } + } + + test.describe("should have a 'dispose' method", () => { + test("that calls the disconnectedCallback for all renderers in 'customElementInstanceStack'", () => { + const renderInfo = new DefaultRenderInfo([TestRenderer]); + const renderers = [new TestRenderer("test"), new TestRenderer("test"), new TestRenderer("test")] + renderInfo.customElementInstanceStack.push(...renderers); + + renderers.forEach(x => {x.connectedCallback(); expect(x.connected).toBe(true)}) + + renderInfo.dispose(); + + renderers.forEach(x => expect(x.connected).toBe(false)) + }); + test("that calls the disconnectedCallback for all renderers in 'renderCustomElementList'", () => { + const renderInfo = new DefaultRenderInfo([TestRenderer]); + const renderers = [new TestRenderer("test"), new TestRenderer("test"), new TestRenderer("test")] + renderInfo.renderedCustomElementList.push(...renderers); + + renderers.forEach(x => {x.connectedCallback(); expect(x.connected).toBe(true)}) + + renderInfo.dispose(); + + renderers.forEach(x => expect(x.connected).toBe(false)) + }); + + test("that removes all renderers from 'renderCustomElementList' and 'customElementInstanceStack", () => { + const renderInfo = new DefaultRenderInfo([TestRenderer]); + const rendered = [new TestRenderer("test"), new TestRenderer("test"), new TestRenderer("test")]; + const instances = [new TestRenderer("test"), new TestRenderer("test"), new TestRenderer("test")]; + renderInfo.renderedCustomElementList.push(...rendered); + renderInfo.customElementInstanceStack.push(...instances); + + expect(renderInfo.renderedCustomElementList.length).toBe(3); + expect(renderInfo.customElementInstanceStack.length).toBe(3); + renderInfo.dispose(); + + expect(renderInfo.renderedCustomElementList.length).toBe(0); + expect(renderInfo.customElementInstanceStack.length).toBe(0); + }); + + }) +}); diff --git a/packages/web-components/fast-ssr/src/render-info.ts b/packages/web-components/fast-ssr/src/render-info.ts index a07f9dc63f3..cea31f1d130 100644 --- a/packages/web-components/fast-ssr/src/render-info.ts +++ b/packages/web-components/fast-ssr/src/render-info.ts @@ -2,6 +2,7 @@ * This file was ported from {@link https://github.com/lit/lit/tree/main/packages/labs/ssr}. * Please see {@link ../ACKNOWLEDGEMENTS.md} */ +import { Disposable } from "@microsoft/fast-element"; import { ConstructableElementRenderer, ElementRenderer, @@ -10,7 +11,7 @@ import { /** * @beta */ -export type RenderInfo = { +export interface RenderInfo extends Disposable { /** * Element renderers to use */ @@ -23,9 +24,15 @@ export type RenderInfo = { /** * Stack of open host custom elements (n-1 will be n's host) + * @deprecated - use {@link (RenderInfo:interface).customElementInstanceStack} */ customElementHostStack: ElementRenderer[]; -}; + + /** + * A list of all Rendered custom elements. + */ + renderedCustomElementList: ElementRenderer[]; +} /** * Default RenderInfo implementation @@ -35,5 +42,20 @@ export type RenderInfo = { export class DefaultRenderInfo implements RenderInfo { public customElementHostStack: ElementRenderer[] = []; public customElementInstanceStack: ElementRenderer[] = []; + public renderedCustomElementList: ElementRenderer[] = []; constructor(public elementRenderers: ConstructableElementRenderer[] = []) {} + + public dispose(): void { + for (const rendered of this.renderedCustomElementList) { + rendered.disconnectedCallback(); + } + + for (let i = this.customElementInstanceStack.length - 1; i >= 0; i--) { + this.customElementInstanceStack[i].disconnectedCallback(); + } + + // renderedCustomElementList was kept to cleanup custom elements after render; clear it after disconnect so it can be GC'd if needed + this.renderedCustomElementList = []; + this.customElementInstanceStack = []; + } } diff --git a/packages/web-components/fast-ssr/src/request-storage.spec.ts b/packages/web-components/fast-ssr/src/request-storage.spec.ts index 18f9ef8e870..6436d37308f 100644 --- a/packages/web-components/fast-ssr/src/request-storage.spec.ts +++ b/packages/web-components/fast-ssr/src/request-storage.spec.ts @@ -16,19 +16,34 @@ test.describe("RequestStorageManager", () => { test("can create backing storage with a custom window", () => { const w = createWindow(); const storage = RequestStorageManager.createStorage({ - createWindow: () => w + createWindow: () => w, }); expect(storage).toBeInstanceOf(Map); expect(storage.get("window")).toBe(w); }); + test("should pass in request object to createWindow middleware", () => { + function createWindowShim(req: any) { + return { + id: req.headers["id"] + }; + } + const request = { headers: { id: "test" } }; + const storage = RequestStorageManager.createStorage({ + createWindow: createWindowShim, + }, request); + + expect(storage).toBeInstanceOf(Map); + expect(storage.get("window")).toStrictEqual({id: "test"}); + }); + test("can create backing storage with initial values", () => { const initialValues = new Map(); initialValues.set("hello", "world"); const storage = RequestStorageManager.createStorage({ - storage: initialValues + storage: initialValues, }); expect(storage).toBeInstanceOf(Map); @@ -40,7 +55,7 @@ test.describe("RequestStorageManager", () => { initialValues.set("hello", "world"); const storage = RequestStorageManager.createStorage({ - storage: initialValues + storage: initialValues, }); let captured; @@ -61,7 +76,6 @@ test.describe("RequestStorageManager", () => { }); test("can get different value from global in a storage scope", () => { - // window is part of perRequestGlobals setup by installDOMShim (window as any)["hello"] = "world"; RequestStorageManager.installDOMShim(); diff --git a/packages/web-components/fast-ssr/src/request-storage.ts b/packages/web-components/fast-ssr/src/request-storage.ts index ec5a7c61993..f21a1cf8fbf 100644 --- a/packages/web-components/fast-ssr/src/request-storage.ts +++ b/packages/web-components/fast-ssr/src/request-storage.ts @@ -101,7 +101,7 @@ export type StorageOptions = { /** * A custom window creation function. */ - createWindow?: () => { [key: string]: unknown }; + createWindow?: (req: any) => { [key: string]: unknown }; /** * Initial values to setup in the backing store. @@ -121,6 +121,7 @@ const perRequestGlobals = [ "removeEventListener", "window", "document", + "location", ]; const perRequestGetters = perRequestGlobals.reduce((accum, key) => { @@ -250,9 +251,12 @@ export const RequestStorageManager = Object.freeze({ * @param options - The options used when creating the backing store for RequestStorage. * @returns A Map suitable as a backing store for RequestStorage. */ - createStorage(options: StorageOptions = defaultOptions): Map { + createStorage( + options: StorageOptions = defaultOptions, + req: any = null + ): Map { const storage = new Map(); - const window = options.createWindow ? options.createWindow() : createWindow(); + const window = options.createWindow ? options.createWindow(req) : createWindow(); storage.set("window", window); @@ -289,7 +293,7 @@ export const RequestStorageManager = Object.freeze({ RequestStorageManager.installDOMShim(); return (req: any, res: any, next: () => any): void => { - const storage = RequestStorageManager.createStorage(options); + const storage = RequestStorageManager.createStorage(options, req); RequestStorageManager.run(storage, next); }; }, diff --git a/packages/web-components/fast-ssr/src/styles/fast-style-config.ts b/packages/web-components/fast-ssr/src/styles/fast-style-config.ts new file mode 100644 index 00000000000..6c4820d636e --- /dev/null +++ b/packages/web-components/fast-ssr/src/styles/fast-style-config.ts @@ -0,0 +1 @@ +export const fastStyleTagName = "fast-style"; diff --git a/packages/web-components/fast-ssr/src/styles/fast-style.fixture.html b/packages/web-components/fast-ssr/src/styles/fast-style.fixture.html index f58a43a3128..d4065dd99e2 100644 --- a/packages/web-components/fast-ssr/src/styles/fast-style.fixture.html +++ b/packages/web-components/fast-ssr/src/styles/fast-style.fixture.html @@ -138,7 +138,7 @@ border-radius: calc(var(--control-corner-radius) * 1px); box-shadow: 0 0 calc((var(--elevation) * 0.225px) + 2px) rgba(0, 0, 0, calc(0.11 * (2 - var(--background-luminance, 1)))), 0 calc(var(--elevation) * 0.4px) calc((var(--elevation) * 0.9px)) rgba(0, 0, 0, calc(0.13 * (2 - var(--background-luminance, 1))));}" > - +

Heading

@@ -199,7 +199,7 @@

Heading