From c29d42390fb96a5497717a30a22d42aaf026c655 Mon Sep 17 00:00:00 2001 From: Kevin Schaaf Date: Wed, 1 Jul 2020 18:55:03 -0700 Subject: [PATCH 1/3] Make the custom element instance responsible for rendering. * Adds minimal attribute API to the dom-shim * custom-element opcodes refactored to call setAttribute for static attributes, connectedCallback after completing all bindings (which set properties/attributes directly on the instance), and then render the children based on a new `ssrRenderChildren` field expected to be set on the CE instance by the time `connectedCallback` returns * This eliminates the need for an explicit `ElementRenderer` class, allowing us to reuse as much logic as possible from the CE base class * `lit-element-renderer.ts` is now responsible for patching `LitElement.render` to set a children-rendering generator onto `instance.ssrRenderChildren` and to patch `connectedCallback` to synchronously call `performUpdate()`. * Note that `LitElement:performUpdate()` is called with a new flag to elide the `(first)updated` callbacks, since these (a) can't do anything good to the SSR output since they occur after `render()`, and (b) may more likely touch DOM. --- src/lib/dom-shim.ts | 40 +++++++- src/lib/element-renderer.ts | 44 --------- src/lib/lit-element-renderer.ts | 76 ++++++--------- src/lib/render-html-file-impl.ts | 1 - src/lib/render-lit-html.ts | 114 ++++++++++++++-------- src/lib/util/escaping.ts | 41 ++++++++ src/test/integration/tests/basic.ts | 17 ++-- src/test/test-files/render-test-module.ts | 2 +- 8 files changed, 192 insertions(+), 143 deletions(-) delete mode 100644 src/lib/element-renderer.ts create mode 100644 src/lib/util/escaping.ts diff --git a/src/lib/dom-shim.ts b/src/lib/dom-shim.ts index 2b14c99..2ba2c9c 100644 --- a/src/lib/dom-shim.ts +++ b/src/lib/dom-shim.ts @@ -30,19 +30,49 @@ import fetch from 'node-fetch'; */ export const getWindow = (props: {[key: string]: any} = {}): {[key: string]: any} => { - const HTMLElement = class HTMLElement { + const attributes: WeakMap> = new WeakMap(); + const attributesFor = (element: any) => { + let attrs = attributes.get(element); + if (!attrs) { + attributes.set(element, attrs = new Map()); + } + return attrs; + } + + abstract class HTMLElement { + get attributes() { + return Array.from(attributesFor(this)).map(([name, value]) => ({name, value})); + } + abstract attributeChangedCallback?(name: string, old: string | null, value: string | null): void; + setAttribute(name: string, value: string) { + const old = attributesFor(this).get(name) || null; + attributesFor(this).set(name, value); + if (this.attributeChangedCallback) { + this.attributeChangedCallback(name, old, value); + } + } + removeAttribute(name: string) { + const old = attributesFor(this).get(name) || null; + attributesFor(this).delete(name); + if (this.attributeChangedCallback) { + this.attributeChangedCallback(name, old, null); + } + } + hasAttribute(name: string) { + return attributesFor(this).has(name); + } attachShadow() { - return {}; + return { host: this }; } }; - const Document = class Document { + class Document { get adoptedStyleSheets() { return []; } }; - const CSSStyleSheet = class CSSStyleSheet { + class CSSStyleSheet { replace() {} }; @@ -51,7 +81,7 @@ export const getWindow = (props: {[key: string]: any} = {}): {[key: string]: any observedAttributes: string[]; }; - const CustomElementRegistry = class CustomElementRegistry { + class CustomElementRegistry { __definitions = new Map(); define(name: string, ctor: {new (): HTMLElement}) { diff --git a/src/lib/element-renderer.ts b/src/lib/element-renderer.ts deleted file mode 100644 index 2dd779f..0000000 --- a/src/lib/element-renderer.ts +++ /dev/null @@ -1,44 +0,0 @@ -/// - -/** - * @license - * Copyright (c) 2019 The Polymer Project Authors. All rights reserved. - * This code may only be used under the BSD style license found at - * http://polymer.github.io/LICENSE.txt - * The complete set of authors may be found at - * http://polymer.github.io/AUTHORS.txt - * The complete set of contributors may be found at - * http://polymer.github.io/CONTRIBUTORS.txt - * Code distributed by Google as part of the polymer project is also - * subject to an additional IP rights grant found at - * http://polymer.github.io/PATENTS.txt - */ - -export type Constructor = {new (): T}; - -/** - * An object that renders elements of a certain type. - */ -export interface ElementRenderer { - element: HTMLElement; - - /** - * Render a single element's ShadowRoot contents. - * - */ - renderElement(): IterableIterator; - - /** - * Handles setting a property. - * @param name Name of the property - * @param value Value of the property - */ - setProperty(name: string, value: unknown): void; - - /** - * Handles setting an attribute on an element. - * @param name Name of the attribute - * @param value Value of the attribute - */ - setAttribute(name: string, value: string | null): void; -} diff --git a/src/lib/lit-element-renderer.ts b/src/lib/lit-element-renderer.ts index 8fcc1af..bea3f91 100644 --- a/src/lib/lit-element-renderer.ts +++ b/src/lib/lit-element-renderer.ts @@ -12,60 +12,44 @@ * http://polymer.github.io/PATENTS.txt */ -import {ElementRenderer} from './element-renderer.js'; -import {LitElement, TemplateResult, CSSResult} from 'lit-element'; -import {render, renderValue, RenderInfo} from './render-lit-html.js'; +import {LitElement, CSSResult} from 'lit-element'; +import {render} from './render-lit-html.js'; -export type Constructor = {new (): T}; - -/** - * An object that renders elements of a certain type. - */ -export class LitElementRenderer implements ElementRenderer { - constructor(public element: LitElement) {} - - *renderElement(): IterableIterator { - const renderResult = ((this.element as unknown) as { - render(): TemplateResult; - }).render(); +function *renderChildren(element: LitElement, result: any, useShadowRoot: boolean): IterableIterator { + if (useShadowRoot) { yield ''; + yield ''; } - setProperty(name: string, value: unknown) { - (this.element as any)[name] = value; + // Render html + yield* render(result); + if (useShadowRoot) { + yield ''; } +} - setAttribute(name: string, value: string | null) { - // Note, this should always exist for LitElement, but we're not yet - // explicitly checking for LitElement. - if (this.element.attributeChangedCallback) { - this.element.attributeChangedCallback(name, null, value as string); - } +// Install SSR render hook +LitElement.render = function(result: unknown, container: Element | DocumentFragment, _options: any) { + const instance = (container as ShadowRoot).host ? (container as ShadowRoot).host : container; + if (!(instance instanceof LitElement)) { + throw new Error('For compatibiltiy with SSR, renderRoot must either be the shadowRoot of the LitElement or the LitElement itself.') } + instance.ssrRenderChildren = renderChildren(instance, result, Boolean((container as ShadowRoot).host)); +}; - renderLight(renderInfo: RenderInfo) { - const templateResult = (this.element as any)?.renderLight(); - return templateResult ? renderValue(templateResult, renderInfo) : ''; - } +// Make rendering synchronous to connectedCallback() +const connectedCallback = LitElement.prototype.connectedCallback; +LitElement.prototype.connectedCallback = function() { + connectedCallback.call(this); + this.performUpdate(true); } diff --git a/src/lib/render-html-file-impl.ts b/src/lib/render-html-file-impl.ts index b79c878..81cd762 100644 --- a/src/lib/render-html-file-impl.ts +++ b/src/lib/render-html-file-impl.ts @@ -37,7 +37,6 @@ export async function renderFile(options: RenderAppOptions) { fetch, process: {env: {NODE_ENV: 'production', ...options.env || {}}} }); - debugger // Make sure file exists; if not, use fallback let file = path.join(options.root, url.pathname); let exists = false; diff --git a/src/lib/render-lit-html.ts b/src/lib/render-lit-html.ts index 10720b2..0309720 100644 --- a/src/lib/render-lit-html.ts +++ b/src/lib/render-lit-html.ts @@ -43,13 +43,12 @@ import { isElement, } from './util/parse5-utils.js'; -import {CSSResult} from 'lit-element'; -import StyleTransformer from '@webcomponents/shadycss/src/style-transformer.js'; import {isDirective} from 'lit-html/lib/directive.js'; import {isRenderLightDirective} from 'lit-element/lib/render-light.js'; -import {LitElementRenderer} from './lit-element-renderer.js'; +import './lit-element-renderer.js'; import {reflectedAttributeName} from './reflected-attributes.js'; import { TemplateFactory } from 'lit-html/lib/template-factory'; +import {escapeAttribute, escapeTextContent} from './util/escaping.js'; declare module 'parse5' { interface DefaultTreeElement { @@ -181,14 +180,19 @@ type AttributePartOp = { type CustomElementOpenOp = { type: 'custom-element-open'; tagName: string; - ctor: any; + ctor: RenderableCustomElement; + staticAttributes: Map; }; /** * Operation to render a custom element, usually its shadow root. */ -type CustomElementRenderOp = { - type: 'custom-element-render'; +type CustomElementRenderChildrenOp = { + type: 'custom-element-render-children'; +}; + +type CustomElementRenderAttributesOp = { + type: 'custom-element-render-attributes'; }; /** @@ -204,7 +208,8 @@ type Op = | NodePartOp | AttributePartOp | CustomElementOpenOp - | CustomElementRenderOp + | CustomElementRenderAttributesOp + | CustomElementRenderChildrenOp | CustomElementClosedOp; const getTemplate = (result: TemplateResult) => { @@ -319,6 +324,9 @@ const getTemplate = (result: TemplateResult) => { type: 'custom-element-open', tagName, ctor, + staticAttributes: new Map(node.attrs + .filter(attr => !attr.name.endsWith(boundAttributeSuffix)) + .map(attr => ([attr.name, attr.value]))) }); } } @@ -350,12 +358,29 @@ const getTemplate = (result: TemplateResult) => { useCustomElementInstance: ctor !== undefined, }); skipTo(attrEndOffset); + } else if (node.isDefinedCustomElement) { + // We will wait until after connectedCallback() and render all + // custom element attributes then + const attrSourceLocation = node.sourceCodeLocation!.attrs[ + attr.name + ]; + flushTo(attrSourceLocation.startOffset); + skipTo(attrSourceLocation.endOffset); } } } if (writeTag) { - flushTo(node.sourceCodeLocation!.startTag.endOffset); + if (node.isDefinedCustomElement) { + flushTo(node.sourceCodeLocation!.startTag.endOffset - 1); + ops.push({ + type: 'custom-element-render-attributes' + }); + flush('>'); + skipTo(node.sourceCodeLocation!.startTag.endOffset); + } else { + flushTo(node.sourceCodeLocation!.startTag.endOffset); + } } if (boundAttrsCount > 0) { @@ -364,7 +389,7 @@ const getTemplate = (result: TemplateResult) => { if (ctor !== undefined) { ops.push({ - type: 'custom-element-render', + type: 'custom-element-render-children', }); } } @@ -384,8 +409,14 @@ const getTemplate = (result: TemplateResult) => { return t; }; +type RenderableCustomElement = HTMLElement & { + new(): RenderableCustomElement; + connectedCallback?(): void; + ssrRenderChildren?: IterableIterator | undefined; +}; + export type RenderInfo = { - customElementInstanceStack: Array; + customElementInstanceStack: Array; }; declare global { @@ -394,22 +425,16 @@ declare global { } } -/** - * Returns the scoped style sheets required by all elements currently defined. - */ -export const getScopedStyles = () => { - const scopedStyles = []; - for (const [tagName, definition] of (customElements as any).__definitions) { - const styles = [(definition.ctor as any).styles].flat(Infinity); - for (const style of styles) { - if (style instanceof CSSResult) { - const scoped = StyleTransformer.css(style.cssText, tagName); - scopedStyles.push(scoped); - } +function* renderCustomElementAttributes(instance: RenderableCustomElement): IterableIterator { + const attrs = instance.attributes; + for (let i=0, name, value; i { yield* renderValue(value, {customElementInstanceStack: []}); @@ -425,7 +450,7 @@ export function* renderValue( const instance = getLast(renderInfo.customElementInstanceStack); // TODO, move out of here into something LitElement specific if (instance !== undefined) { - yield* instance.renderLight(renderInfo); + yield* renderValue((instance as any)?.renderLight(), renderInfo); } value = null; } else if (isDirective(value)) { @@ -445,8 +470,7 @@ export function* renderValue( yield* renderValue(item, renderInfo); } } else { - // TODO: convert value to string, handle arrays, directives, etc. - yield String(value); + yield escapeTextContent(String(value)); } } yield ``; @@ -518,11 +542,10 @@ export function* renderTemplateResult( const value = committer.getValue(); if (value !== noChange) { if (instance !== undefined) { - instance.setProperty(propertyName, value); + (instance as any)[propertyName] = value; } if (reflectedName !== undefined) { - // TODO: escape the attribute string - yield `${reflectedName}="${value}"`; + yield `${reflectedName}="${escapeAttribute(String(value))}"`; } } @@ -549,13 +572,13 @@ export function* renderTemplateResult( part.setValue(result.values[partIndex + i]); part.resolvePendingDirective(); }); - // TODO: escape the attribute string const value = committer.getValue(); if (value !== noChange) { if (instance !== undefined) { instance.setAttribute(attributeName, value as string); + } else { + yield `${attributeName}="${escapeAttribute(String(value))}"`; } - yield `${attributeName}="${value}"`; } } partIndex += statics.length - 1; @@ -566,19 +589,34 @@ export function* renderTemplateResult( // Instantiate the element and stream its render() result let instance = undefined; try { - const element = new ctor(); - (element as any).tagName = op.tagName; - instance = new LitElementRenderer(element); + instance = new ctor(); + (instance as any).tagName = op.tagName; + for (const [name, value] of op.staticAttributes) { + instance.setAttribute(name, value); + } } catch (e) { - console.error('Exception in custom element constructor', e); + console.error(`Exception in custom element callback for ${op.tagName}`, e); } renderInfo.customElementInstanceStack.push(instance); break; } - case 'custom-element-render': { + case 'custom-element-render-attributes': { const instance = getLast(renderInfo.customElementInstanceStack); if (instance !== undefined) { - yield* instance.renderElement(); + if (instance.connectedCallback) { + instance?.connectedCallback(); + } + if (renderInfo.customElementInstanceStack.length > 1) { + yield ' defer-hydration'; + } + yield* renderCustomElementAttributes(instance); + } + break; + } + case 'custom-element-render-children': { + const instance = getLast(renderInfo.customElementInstanceStack); + if (instance !== undefined && instance.ssrRenderChildren) { + yield* instance.ssrRenderChildren; } break; } diff --git a/src/lib/util/escaping.ts b/src/lib/util/escaping.ts new file mode 100644 index 0000000..094cc46 --- /dev/null +++ b/src/lib/util/escaping.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright (c) 2020 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ + +// http://www.whatwg.org/specs/web-apps/current-work/multipage/the-end.html#escapingString +const escapeAttrRegExp = /[&\u00A0"]/g; +const escapeDataRegExp = /[&\u00A0<>]/g; + +const escapeReplace = (c: string) => { + switch (c) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case '\u00A0': + return ' '; + } + throw new Error('unexpected'); +} + +export const escapeAttribute = (s: string) => { + return s.replace(escapeAttrRegExp, escapeReplace); +} + +export const escapeTextContent = (s: string) => { + return s.replace(escapeDataRegExp, escapeReplace); +} diff --git a/src/test/integration/tests/basic.ts b/src/test/integration/tests/basic.ts index 14d66b8..6cbea25 100644 --- a/src/test/integration/tests/basic.ts +++ b/src/test/integration/tests/basic.ts @@ -3311,11 +3311,6 @@ export const tests: {[name: string] : SSRTest} = { 'LitElement: Reflected property binding': () => { return { - // TODO: lit-element-renderer does not yet support reflected properties; - // should the renderer call into `addPropertiesForElement`? The first time - // a renderer is created for a given element type? - // https://github.com/PolymerLabs/lit-ssr/issues/61 - skip: true, registerElements() { class LEReflectedBinding extends LitElement { @property({reflect: true}) @@ -3345,7 +3340,7 @@ export const tests: {[name: string] : SSRTest} = { { args: ['boundProp2'], async check(assert: Chai.Assert, dom: HTMLElement) { - const el = dom.querySelector('le-prop-binding')! as LitElement; + const el = dom.querySelector('le-reflected-binding')! as LitElement; await el.updateComplete; assert.strictEqual((el as any).prop, 'boundProp2'); }, @@ -3356,6 +3351,10 @@ export const tests: {[name: string] : SSRTest} = { }, ], stableSelectors: ['le-reflected-binding'], + // LitElement unconditionally sets reflecting properties to attributes + // on a property change, even if the attribute was already there + expectMutationsDuringUpgrade: true, + expectMutationsDuringHydration: true, }; }, @@ -3377,9 +3376,10 @@ export const tests: {[name: string] : SSRTest} = { expectations: [ { args: ['boundProp1'], - async check(_assert: Chai.Assert, dom: HTMLElement) { + async check(assert: Chai.Assert, dom: HTMLElement) { const el = dom.querySelector('le-attr-binding')! as LitElement; await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp1'); }, html: { root: ``, @@ -3388,9 +3388,10 @@ export const tests: {[name: string] : SSRTest} = { }, { args: ['boundProp2'], - async check(_assert: Chai.Assert, dom: HTMLElement) { + async check(assert: Chai.Assert, dom: HTMLElement) { const el = dom.querySelector('le-attr-binding')! as LitElement; await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp2'); }, html: { root: ``, diff --git a/src/test/test-files/render-test-module.ts b/src/test/test-files/render-test-module.ts index 4341881..e5f2514 100644 --- a/src/test/test-files/render-test-module.ts +++ b/src/test/test-files/render-test-module.ts @@ -17,7 +17,7 @@ import {repeat} from 'lit-html/directives/repeat.js'; import {classMap} from 'lit-html/directives/class-map.js'; import {LitElement, css, property, customElement} from 'lit-element'; -export {render, getScopedStyles} from '../../lib/render-lit-html.js'; +export {render} from '../../lib/render-lit-html.js'; /* Real Tests */ From d9ba233b57675cd9a18c37e51500535d3e73461c Mon Sep 17 00:00:00 2001 From: Kevin Schaaf Date: Mon, 6 Jul 2020 10:53:48 -0700 Subject: [PATCH 2/3] Use explicit deferChildHydration render option, since custom element stack is restarted for every render. --- src/lib/lit-element-renderer.ts | 2 +- src/lib/render-lit-html.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/lit-element-renderer.ts b/src/lib/lit-element-renderer.ts index bea3f91..1afcd85 100644 --- a/src/lib/lit-element-renderer.ts +++ b/src/lib/lit-element-renderer.ts @@ -32,7 +32,7 @@ function *renderChildren(element: LitElement, result: any, useShadowRoot: boolea } // Render html - yield* render(result); + yield* render(result, {deferChildHydration: true}); if (useShadowRoot) { yield ''; } diff --git a/src/lib/render-lit-html.ts b/src/lib/render-lit-html.ts index 0309720..5241f3f 100644 --- a/src/lib/render-lit-html.ts +++ b/src/lib/render-lit-html.ts @@ -417,6 +417,7 @@ type RenderableCustomElement = HTMLElement & { export type RenderInfo = { customElementInstanceStack: Array; + deferChildHydration?: boolean; }; declare global { @@ -436,8 +437,12 @@ function* renderCustomElementAttributes(instance: RenderableCustomElement): Iter } } -export function* render(value: unknown): IterableIterator { - yield* renderValue(value, {customElementInstanceStack: []}); +export type SSRRenderOptions = { + deferChildHydration?: boolean; +}; + +export function* render(value: unknown, options?: SSRRenderOptions): IterableIterator { + yield* renderValue(value, {customElementInstanceStack: [], ...(options)}); } export function* renderValue( @@ -606,7 +611,7 @@ export function* renderTemplateResult( if (instance.connectedCallback) { instance?.connectedCallback(); } - if (renderInfo.customElementInstanceStack.length > 1) { + if (renderInfo.deferChildHydration) { yield ' defer-hydration'; } yield* renderCustomElementAttributes(instance); From 7230ca191fbbbbbf65e4f4425714806e23410172 Mon Sep 17 00:00:00 2001 From: Kevin Schaaf Date: Mon, 6 Jul 2020 12:34:34 -0700 Subject: [PATCH 3/3] Ignore defer-hydration attribute --- src/test/integration/client/basic_test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/integration/client/basic_test.ts b/src/test/integration/client/basic_test.ts index 170a5ff..bace19b 100644 --- a/src/test/integration/client/basic_test.ts +++ b/src/test/integration/client/basic_test.ts @@ -25,6 +25,9 @@ LitElement.hydrate = hydrate; const assert = chai.assert; +// Types don't seem to include options argument +const assertLightDom: ((el: Element | ShadowRoot, str: string, opt: any) => void) = assert.lightDom.equal; + /** * Checks a tree of expected HTML against the DOM; the expected HTML can either * be a string or an object containing keys of querySelector queries mapped to @@ -70,7 +73,7 @@ const assertHTML = (container: Element | ShadowRoot, html: SSRExpectedHTML): voi const subHtml = html[query]; if (query === 'root') { assert.typeOf(subHtml, 'string', `html expectation for ':root' must be a string.`); - assert.lightDom.equal(container, subHtml as string); + assertLightDom(container, subHtml as string, {ignoreAttributes: ['defer-hydration']}); } else { const subContainers = Array.from(container.querySelectorAll(query)) ;