diff --git a/packages/web-components/fast-ssr/package.json b/packages/web-components/fast-ssr/package.json index 3d4dc082fda..eabf18d2007 100644 --- a/packages/web-components/fast-ssr/package.json +++ b/packages/web-components/fast-ssr/package.json @@ -32,9 +32,13 @@ "description": "A package for rendering FAST components outside the browser.", "main": "./dist/esm/exports.js", "types": "./dist/dts/exports.d.ts", + "exports": { + "." : "./dist/esm/exports.js", + "./install-dom-shim": "./dist/esm/dom-shim.js" + }, "private": true, "dependencies": { - "@lit-labs/ssr": "^1.0.0-rc.2", + "@lit-labs/ssr": "^2.1.0", "@microsoft/fast-element": "^1.5.0", "parse5": "^6.0.1", "tslib": "^1.11.1" diff --git a/packages/web-components/fast-ssr/src/dom-shim.ts b/packages/web-components/fast-ssr/src/dom-shim.ts index 2d3284c1dde..95bf68dddab 100644 --- a/packages/web-components/fast-ssr/src/dom-shim.ts +++ b/packages/web-components/fast-ssr/src/dom-shim.ts @@ -1,24 +1,151 @@ import { installWindowOnGlobal } from "@lit-labs/ssr/lib/dom-shim.js"; -installWindowOnGlobal(); - -// Implement shadowRoot getter on HTMLElement. -// Can be removed if https://github.com/lit/lit/issues/2652 -// is integrated. -class PatchedHTMLElement extends HTMLElement { - #shadowRoot: ShadowRoot | null = null; - get shadowRoot() { +class DOMTokenList { + #tokens = new Set(); + + public add(value: string) { + this.#tokens.add(value); + } + + public remove(value: string) { + this.#tokens.delete(value); + } + + public contains(value: string) { + return this.#tokens.has(value); + } + + public toggle(value: string, force?: boolean) { + const add = force === undefined ? !this.contains(value) : force; + + if (add) { + this.add(value); + return true; + } else { + this.remove(value); + return false; + } + } + + public toString() { + return Array.from(this.#tokens).join(" "); + } + + *[Symbol.iterator]() { + yield* this.#tokens.values(); + } +} +class Node { + appendChild() {} + removeChild() {} +} + +class Element extends Node {} + +abstract class HTMLElement extends Element { + #attributes = new Map(); + #shadowRoot: null | ShadowRoot = null; + + public readonly classList = new DOMTokenList(); + + public get attributes(): { name: string; value: string }[] { + return Array.from(this.#attributes).map(([name, value]) => { + if (typeof value === "string") { + return { name, value }; + } else { + return { name, value: value.toString() }; + } + }); + } + + public get shadowRoot() { return this.#shadowRoot; } - attachShadow(init: ShadowRootInit) { - const root = super.attachShadow(init); - if (init.mode === "open") { - this.#shadowRoot = root; + public abstract attributeChangedCallback?( + name: string, + old: string | null, + value: string | null + ): void; + + public setAttribute(name: string, value: string) { + let _value: string | DOMTokenList = value; + if (name === "class") { + _value = new DOMTokenList(); + value.split(" ").forEach(className => { + (_value as DOMTokenList).add(className); + }); } + this.#attributes.set(name, _value); + } + + public removeAttribute(name: string) { + this.#attributes.delete(name); + } + + public hasAttribute(name: string) { + return this.#attributes.has(name); + } + + public attachShadow(init: ShadowRootInit) { + const shadowRoot = ({ host: this } as unknown) as ShadowRoot; + if (init && init.mode === "open") { + this.#shadowRoot = shadowRoot; + } + return shadowRoot; + } + + public getAttribute(name: string) { + const value = this.#attributes.get(name); + return value === undefined + ? null + : value instanceof DOMTokenList + ? value.toString() + : value; + } +} - return root; +class Document { + head = new Node(); + adoptedStyleSheets = []; + createTreeWalker() { + return {}; + } + createTextNode() { + return {}; + } + createElement(tagName: string) { + return { tagName }; } + querySelector() { + return undefined; + } + addEventListener() {} +} + +class CSSStyleDeclaration { + setProperty() {} } -window.HTMLElement = PatchedHTMLElement; +class CSSStyleSheet { + get cssRules() { + return [{ style: new CSSStyleDeclaration() }]; + } + replace() {} + insertRule() { + return 0; + } +} + +class MediaQueryList { + addListener() {} + matches = false; +} +installWindowOnGlobal({ + matchMedia: () => new MediaQueryList(), + HTMLElement, + Document, + document: new Document(), + Node, + CSSStyleSheet, +}); diff --git a/packages/web-components/fast-ssr/src/element-renderer/elemenent-renderer.spec.ts b/packages/web-components/fast-ssr/src/element-renderer/elemenent-renderer.spec.ts index a47d779bc30..495ac72101b 100644 --- a/packages/web-components/fast-ssr/src/element-renderer/elemenent-renderer.spec.ts +++ b/packages/web-components/fast-ssr/src/element-renderer/elemenent-renderer.spec.ts @@ -28,12 +28,12 @@ test.describe("FASTElementRenderer", () => { test(`should render stylesheets as 'style' elements by default`, () => { const { templateRenderer, defaultRenderInfo} = fastSSR(); const result = consolidate(templateRenderer.render(html``, defaultRenderInfo)); - expect(result).toBe(""); + expect(result).toBe(""); }); test(`should render stylesheets as 'fast-style' elements when configured`, () => { const { templateRenderer, defaultRenderInfo} = fastSSR({useFASTStyle: true}); const result = consolidate(templateRenderer.render(html``, defaultRenderInfo)); - expect(result).toBe(``); + expect(result).toBe(``); }); }); }); 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 b31e8cc0ee4..93998ab8fed 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 @@ -1,8 +1,9 @@ import { ElementRenderer, RenderInfo } from "@lit-labs/ssr"; import { Aspect, DOM, ExecutionContext, FASTElement } from "@microsoft/fast-element"; +import { StyleRenderer } from "../styles/style-renderer.js"; import { TemplateRenderer } from "../template-renderer/template-renderer.js"; import { SSRView } from "../view.js"; -import { StyleRenderer } from "../styles/style-renderer.js"; +import { FASTSSRStyleStrategy } from "./style-strategy.js"; export abstract class FASTElementRenderer extends ElementRenderer { /** @@ -88,6 +89,7 @@ export abstract class FASTElementRenderer extends ElementRenderer { if (ctor) { this.element = new ctor() as FASTElement; + (this.element as any).tagName = tagName; } else { throw new Error( `FASTElementRenderer was unable to find a constructor for a custom element with the tag name '${tagName}'.` @@ -110,10 +112,12 @@ export abstract class FASTElementRenderer extends ElementRenderer { */ public *renderShadow(renderInfo: RenderInfo): IterableIterator { const view = this.element.$fastController.view; - const styles = this.element.$fastController.styles; + const styles = FASTSSRStyleStrategy.getStylesFor(this.element); if (styles) { - yield this.styleRenderer.render(styles); + for (const style of styles) { + yield this.styleRenderer.render(style); + } } if (view !== null) { diff --git a/packages/web-components/fast-ssr/src/element-renderer/style-strategy.ts b/packages/web-components/fast-ssr/src/element-renderer/style-strategy.ts index 4ce4a5525aa..9d8c98af413 100644 --- a/packages/web-components/fast-ssr/src/element-renderer/style-strategy.ts +++ b/packages/web-components/fast-ssr/src/element-renderer/style-strategy.ts @@ -1,5 +1,37 @@ -import { StyleStrategy } from "@microsoft/fast-element"; +import { StyleStrategy, StyleTarget } from "@microsoft/fast-element"; + +const sheetsForElement = new WeakMap>(); + +function getOrCreateFor(target: Element): Set { + let set = sheetsForElement.get(target); + if (set) { + return set; + } + + set = new Set(); + sheetsForElement.set(target, set); + + return set; +} + +function isShadowRoot(target: any): target is ShadowRoot { + return !!target.host; +} + export class FASTSSRStyleStrategy implements StyleStrategy { - addStylesTo() {} + addStylesTo(target: StyleTarget) { + if (isShadowRoot(target)) { + const cache = getOrCreateFor(target.host); + + this.styles.forEach(style => cache?.add(style)); + } + } + removeStylesFrom() {} + + constructor(private styles: (string | CSSStyleSheet)[]) {} + + public static getStylesFor(target: Element): Set | null { + return sheetsForElement.get(target) || null; + } } diff --git a/packages/web-components/fast-ssr/src/exports.ts b/packages/web-components/fast-ssr/src/exports.ts index 551163081fd..b26b62370a0 100644 --- a/packages/web-components/fast-ssr/src/exports.ts +++ b/packages/web-components/fast-ssr/src/exports.ts @@ -1,5 +1,10 @@ import { RenderInfo } from "@lit-labs/ssr"; -import { Compiler, ElementStyles, ViewBehaviorFactory } from "@microsoft/fast-element"; +import { + Compiler, + DOM, + ElementStyles, + ViewBehaviorFactory, +} from "@microsoft/fast-element"; import { FASTElementRenderer } from "./element-renderer/element-renderer.js"; import { FASTSSRStyleStrategy } from "./element-renderer/style-strategy.js"; import { @@ -31,6 +36,7 @@ Compiler.setDefaultStrategy( ); ElementStyles.setDefaultStrategy(FASTSSRStyleStrategy); +DOM.setUpdateMode(false); /** * Factory for creating SSR rendering assets. @@ -38,9 +44,9 @@ ElementStyles.setDefaultStrategy(FASTSSRStyleStrategy); * * @example * ```ts - * import "@lit-labs/ssr/lib/install-global-dom-shim.js"; - * import { html } from "@microsoft/fast-element"; + * import "@microsoft/install-dom-shim"; * import fastSSR from "@microsoft/fast-ssr"; + * import { html } from "@microsoft/fast-element"; * const { templateRenderer, defaultRenderInfo } = fastSSR(); * * const streamableSSRResult = templateRenderer.render(html`...`, defaultRenderInfo); diff --git a/packages/web-components/fast-ssr/src/styles/style-renderer.ts b/packages/web-components/fast-ssr/src/styles/style-renderer.ts index a4b3c97702f..26e90004fd7 100644 --- a/packages/web-components/fast-ssr/src/styles/style-renderer.ts +++ b/packages/web-components/fast-ssr/src/styles/style-renderer.ts @@ -20,18 +20,18 @@ function collectStyles(style: ComposableStyles): string { } export interface StyleRenderer { - render(styles: ElementStyles): string; + render(styles: ComposableStyles): string; } export class FASTStyleStyleRenderer implements StyleRenderer { - private static stylesheetCache = new Map(); + private static stylesheetCache = new Map(); private static nextId = (() => { let id = 0; const prefix = "fast-style"; return () => `${prefix}-${id++}`; })(); - public render(styles: ElementStyles): string { + public render(styles: ComposableStyles): string { let id = FASTStyleStyleRenderer.stylesheetCache.get(styles); const content = id === undefined ? collectStyles(styles) : null; let contentAttr = ""; @@ -47,7 +47,7 @@ export class FASTStyleStyleRenderer implements StyleRenderer { } export class StyleElementStyleRenderer implements StyleRenderer { - public render(styles: ElementStyles): string { + public render(styles: ComposableStyles): string { return ``; } } diff --git a/packages/web-components/fast-ssr/src/template-parser/template-parser.ts b/packages/web-components/fast-ssr/src/template-parser/template-parser.ts index 7ddba5e90e2..09794bf608c 100644 --- a/packages/web-components/fast-ssr/src/template-parser/template-parser.ts +++ b/packages/web-components/fast-ssr/src/template-parser/template-parser.ts @@ -13,6 +13,7 @@ import { import { Attribute, DefaultTreeCommentNode, + DefaultTreeDocumentFragment, DefaultTreeElement, DefaultTreeNode, DefaultTreeParentNode, @@ -87,6 +88,18 @@ function isElementNode(node: DefaultTreeNode): node is DefaultTreeElement { return (node as DefaultTreeElement).tagName !== undefined; } +function isDocumentFragment(node: any): node is DefaultTreeDocumentFragment { + return node.nodeName === "#document-fragment"; +} + +function firstElementChild(node: DefaultTreeParentNode): DefaultTreeElement | null { + return ( + (node.childNodes.find(child => isElementNode(child)) as + | DefaultTreeElement + | undefined) || null + ); +} + /** * Parses a template into a set of operation instructions * @param template - The template to parse @@ -117,22 +130,37 @@ export function parseTemplateToOpCodes(template: ViewTemplate): Op[] { } export function parseStringToOpCodes( + /** + * The string to parse + */ templateString: string, - factories: Record + factories: Record, + + /** + * Adjust behavior when parsing a template used + * as a custom element's template + */ + forCustomElement = false ): Op[] { const nodeTree = parseFragment(templateString, { sourceCodeLocationInfo: true }); - if (!("nodeName" in nodeTree)) { + if (!isDocumentFragment(nodeTree)) { // I'm not sure when exactly this is encountered but the type system seems to say it's possible. throw new Error(`Error parsing template`); } + // TypeScript gets confused about what 'nodeTree' is. + // Creating a new var clears that up. + let tree: DefaultTreeParentNode = nodeTree; + /** * Tracks the offset location in the source template string where the last * flushing / skip took place. */ let lastOffset: number | undefined = 0; + let finalOffset: number = templateString.length; + /** * Collection of op codes */ @@ -272,7 +300,7 @@ export function parseStringToOpCodes( * Flush template content from lastIndex to provided offset * @param offset - the offset to flush to */ - function flushTo(offset?: number) { + function flushTo(offset: number = finalOffset) { if (lastOffset === undefined) { throw new Error( `Cannot flush template content from a last offset that is ${typeof lastOffset}.` @@ -302,7 +330,23 @@ export function parseStringToOpCodes( lastOffset = offset; } - traverse(nodeTree, { + /** + * FAST leverages any