From 882eded0b9dbc7d129ae4e634878024a0d7ffc08 Mon Sep 17 00:00:00 2001 From: prabhujayapal <171390049+prabhujayapal@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:34:38 -0700 Subject: [PATCH] Adds support for FASTElement hydration (#6977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for FASTElement hydration # Pull Request ## 📖 Description This PR consolidates Server-Side Rendering (SSR) related changes made by @nicholasrice from a Microsoft internal fork. Below is the commit description from the original PR. --- This PR enables hydration of FAST custom elements from HTML emitted by SSR processes. **This PR has three core areas:** 1. I've added a SSR example application to `/examples` to aid in debugging. This code was ported from external FAST. 2. I've updated `fast-ssr` to accept a `emitHydratableMarkup` attribute. Configuring this option causes markers to be emitted during template rendering. These markers manifest either as element attributes (for attribute bindings / directives) and HTML comments (for content bindings / directives). These markers are used by `fast-element` to identify which elements belong to which views and bindings. 3. `fast-element` has been updated to support hydrating views, details below. **fast-element changes:** 1. Updated `HydratableElementController` to invoke `hydrate` on it's template during connection, also refactored `ElementController` base class to allow better direct extension by making `private` properties `protected`. 2. Added a `HydrationView` class. An instance of this class represents a region of DOM created by a template during the SSR process. When this class binds for the first time, it walks the DOM tree between it's first and last nodes looking for binding markers emitted during `fast-ssr` rendering. When it finds binding markers, it associates the template binding to that DOM location. After all the nodes have been walked and the bindings targeted, behaviors for those bindings are created and bound, hooking up observable behavior. 3. Updated bindings and directives to handle hydration scenarios. Unfortunately, I was not able to identify a good way to separate the hydration behavior from the CSR binding behavior without implementing a large amount of code duplication. Instead, I added a lightweight `isHydratable()` test to determine whether bindings should go through hydration flows. Bindings and directives were then updated to not affect DOM during hydration flows. ### 🎫 Issues * https://github.com/microsoft/fast/issues/5182 * https://github.com/microsoft/fast/issues/5577 ## 👩‍💻 Reviewer Notes ## 📑 Test Plan ## ✅ Checklist ### General - [x] I have included a change request file using `$ yarn change` - [x] I have added tests for my changes. - [x] I have tested my changes. - [ ] I have updated the project documentation to reflect my changes. - [ ] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/master/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/master/CODE_OF_CONDUCT.md#our-standards) for this project. --- ...-66c55455-9b64-4d8c-a517-be10925cfd1a.json | 7 + ...-7a94883b-0ee2-4e65-918c-91a2ce70ebc6.json | 7 + ...-f22b45fd-23fb-4386-82fa-72d59f744cd6.json | 7 + .../fast-element/docs/api-report.api.md | 101 +++- .../src/components/element-controller.spec.ts | 39 +- .../src/components/element-controller.ts | 192 ++++++-- .../src/components/element-hydration.ts | 2 + .../src/components/hydration.spec.ts | 111 ++++- .../fast-element/src/components/hydration.ts | 173 +++++-- .../src/components/install-hydration.ts | 3 +- .../src/hydration/target-builder.ts | 270 +++++++++++ .../web-components/fast-element/src/index.ts | 12 +- .../src/templating/binding.spec.ts | 2 +- .../src/templating/children.spec.ts | 23 - .../fast-element/src/templating/compiler.ts | 12 +- .../src/templating/html-binding-directive.ts | 44 +- .../install-hydratable-view-templates.ts | 23 + .../src/templating/render.spec.ts | 6 +- .../fast-element/src/templating/render.ts | 54 ++- .../fast-element/src/templating/repeat.ts | 172 ++++++- .../src/templating/template.spec.ts | 2 +- .../fast-element/src/templating/template.ts | 31 +- .../fast-element/src/templating/view.ts | 366 +++++++++++++-- .../fast-ssr/docs/api-report.api.md | 45 +- .../web-components/fast-ssr/server/server.ts | 8 + .../fast-ssr/src/configure-fast-element.ts | 4 + .../fast-ssr/src/dom-shim.spec.ts | 91 ++-- .../web-components/fast-ssr/src/dom-shim.ts | 3 + .../fast-ssr/src/element-controller.ts | 14 + ...derer.spec.ts => element-renderer.spec.ts} | 237 ++++++++-- .../src/element-renderer/element-renderer.ts | 99 ++-- .../element-renderer/fast-element-renderer.ts | 240 +++++++--- .../src/element-renderer/interfaces.ts | 1 + .../fast-ssr/src/exports.spec.ts | 29 +- .../web-components/fast-ssr/src/exports.ts | 53 ++- .../fast-command-buffer/index.fixture.html | 4 +- .../fast-ssr/src/render-info.spec.ts | 64 +++ .../fast-ssr/src/render-info.ts | 26 +- .../fast-ssr/src/request-storage.spec.ts | 22 +- .../fast-ssr/src/request-storage.ts | 12 +- .../fast-ssr/src/styles/fast-style-config.ts | 1 + .../src/styles/fast-style.fixture.html | 20 +- .../fast-ssr/src/styles/fast-style.spec.ts | 112 +++-- .../fast-ssr/src/styles/fast-style.ts | 3 +- .../src/styles/style-renderer.spec.ts | 40 +- .../fast-ssr/src/styles/style-renderer.ts | 40 +- .../src/template-cache/controller.spec.ts | 43 ++ .../fast-ssr/src/template-cache/controller.ts | 53 +++ .../src/template-parser/id-generator.ts | 55 +++ .../fast-ssr/src/template-parser/op-codes.ts | 46 +- .../template-parser/template-parser.spec.ts | 113 +++-- .../src/template-parser/template-parser.ts | 127 ++++-- .../src/template-renderer/directives.spec.ts | 40 ++ .../src/template-renderer/directives.ts | 105 ++++- .../hydration-marker-emitter.ts | 15 + .../template-renderer.spec.ts | 430 ++++++++++++++++-- .../template-renderer/template-renderer.ts | 262 +++++------ .../fast-ssr/src/test-utilities/validators.ts | 32 ++ packages/web-components/fast-ssr/src/view.ts | 4 +- 59 files changed, 3369 insertions(+), 783 deletions(-) create mode 100644 change/@microsoft-fast-element-66c55455-9b64-4d8c-a517-be10925cfd1a.json create mode 100644 change/@microsoft-fast-foundation-7a94883b-0ee2-4e65-918c-91a2ce70ebc6.json create mode 100644 change/@microsoft-fast-ssr-f22b45fd-23fb-4386-82fa-72d59f744cd6.json create mode 100644 packages/web-components/fast-element/src/components/element-hydration.ts create mode 100644 packages/web-components/fast-element/src/hydration/target-builder.ts create mode 100644 packages/web-components/fast-element/src/templating/install-hydratable-view-templates.ts create mode 100644 packages/web-components/fast-ssr/src/element-controller.ts rename packages/web-components/fast-ssr/src/element-renderer/{elemenent-renderer.spec.ts => element-renderer.spec.ts} (59%) create mode 100644 packages/web-components/fast-ssr/src/render-info.spec.ts create mode 100644 packages/web-components/fast-ssr/src/styles/fast-style-config.ts create mode 100644 packages/web-components/fast-ssr/src/template-cache/controller.spec.ts create mode 100644 packages/web-components/fast-ssr/src/template-cache/controller.ts create mode 100644 packages/web-components/fast-ssr/src/template-parser/id-generator.ts create mode 100644 packages/web-components/fast-ssr/src/template-renderer/directives.spec.ts create mode 100644 packages/web-components/fast-ssr/src/template-renderer/hydration-marker-emitter.ts create mode 100644 packages/web-components/fast-ssr/src/test-utilities/validators.ts 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