From b1c39ae522e2b7c54178f527d9bfe00c39441120 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Tue, 17 Jan 2023 23:44:36 -0800 Subject: [PATCH 1/6] Use MWC for seach input --- package-lock.json | 34 ++++++++++++++- packages/site-client/package.json | 1 + .../src/pages/catalog/wco-catalog-search.ts | 25 +++++++++-- .../src/lib/catalog/routes/catalog-page.ts | 41 ++++++++++++++++--- packages/site-templates/src/base.ts | 9 ++++ 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87bdf627..ef210c32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3780,6 +3780,20 @@ "@lit-labs/ssr-dom-shim": "^1.0.0" } }, + "node_modules/@material/web": { + "version": "1.0.0-pre.1", + "resolved": "https://registry.npmjs.org/@material/web/-/web-1.0.0-pre.1.tgz", + "integrity": "sha512-wDeQvRPBO/JY5l8K8Fgvkdfnj42uhCJbxn7a3IlQadqoBFoignMogfDrwfTxtV6HHOELktYlHkXgCN8RxgtrIQ==", + "dependencies": { + "lit": "^2.3.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@material/web/node_modules/tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + }, "node_modules/@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -23814,6 +23828,7 @@ "license": "Apache-2.0", "dependencies": { "@lit-labs/task": "^2.0.0", + "@material/web": "^1.0.0-pre.1", "@webcomponents/catalog-api": "^0.0.0", "lit": "^2.6.0", "lit-analyzer": "^1.2.1" @@ -26804,6 +26819,22 @@ "@lit-labs/ssr-dom-shim": "^1.0.0" } }, + "@material/web": { + "version": "1.0.0-pre.1", + "resolved": "https://registry.npmjs.org/@material/web/-/web-1.0.0-pre.1.tgz", + "integrity": "sha512-wDeQvRPBO/JY5l8K8Fgvkdfnj42uhCJbxn7a3IlQadqoBFoignMogfDrwfTxtV6HHOELktYlHkXgCN8RxgtrIQ==", + "requires": { + "lit": "^2.3.0", + "tslib": "^2.4.0" + }, + "dependencies": { + "tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + } + } + }, "@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -27671,6 +27702,7 @@ "version": "file:packages/site-client", "requires": { "@lit-labs/task": "^2.0.0", + "@material/web": "*", "@webcomponents/catalog-api": "^0.0.0", "lit": "^2.6.0", "lit-analyzer": "^1.2.1" @@ -27696,7 +27728,7 @@ "@types/koa": "^2.13.5", "@types/koa__cors": "^3.3.0", "@types/koa__router": "^12.0.0", - "@types/koa-bodyparser": "*", + "@types/koa-bodyparser": "^4.3.10", "@types/koa-conditional-get": "^2.0.0", "@types/koa-etag": "^3.0.0", "@types/koa-static": "^4.0.2", diff --git a/packages/site-client/package.json b/packages/site-client/package.json index f9df9217..77bafd21 100644 --- a/packages/site-client/package.json +++ b/packages/site-client/package.json @@ -87,6 +87,7 @@ }, "dependencies": { "@lit-labs/task": "^2.0.0", + "@material/web": "^1.0.0-pre.1", "@webcomponents/catalog-api": "^0.0.0", "lit": "^2.6.0", "lit-analyzer": "^1.2.1" diff --git a/packages/site-client/src/pages/catalog/wco-catalog-search.ts b/packages/site-client/src/pages/catalog/wco-catalog-search.ts index c44a63f8..35978da3 100644 --- a/packages/site-client/src/pages/catalog/wco-catalog-search.ts +++ b/packages/site-client/src/pages/catalog/wco-catalog-search.ts @@ -6,6 +6,12 @@ import {html, css, LitElement} from 'lit'; import {customElement, query, state} from 'lit/decorators.js'; + +import '@material/web/textfield/outlined-text-field.js'; +import type {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field.js'; + +import '@material/web/icon/icon.js'; + import type {CustomElement} from '@webcomponents/catalog-api/lib/schema.js'; import './wco-element-card.js'; @@ -27,17 +33,30 @@ export class WCOCatalogSearch extends LitElement { grid-auto-rows: 200px; gap: 8px; } + + md-outlined-text-field { + width: 40em; + --md-outlined-field-container-shape-start-start: 28px; + --md-outlined-field-container-shape-start-end: 28px; + --md-outlined-field-container-shape-end-start: 28px; + --md-outlined-field-container-shape-end-end: 28px; + } `; - @query('input') - private _search!: HTMLInputElement; + @query('#search') + private _search!: MdOutlinedTextField; @state() private _elements: Array | undefined; render() { return html` -

Search:

+
+

Web Components.org Catalog

+ search +
${this._elements?.map( (e) => html`` diff --git a/packages/site-server/src/lib/catalog/routes/catalog-page.ts b/packages/site-server/src/lib/catalog/routes/catalog-page.ts index c89ecd4b..8b21a9ac 100644 --- a/packages/site-server/src/lib/catalog/routes/catalog-page.ts +++ b/packages/site-server/src/lib/catalog/routes/catalog-page.ts @@ -11,8 +11,32 @@ import {html} from 'lit'; import {Readable} from 'stream'; import Router from '@koa/router'; +import {LitElementRenderer} from '@lit-labs/ssr/lib/lit-element-renderer.js'; +import { + ElementRenderer, + ElementRendererConstructor, +} from '@lit-labs/ssr/lib/element-renderer.js'; + import '@webcomponents/internal-site-client/lib/pages/catalog/wco-catalog-page.js'; +const excludeElements = ( + renderer: ElementRendererConstructor, + tagNames: Array +) => { + return class ExcludeElementRenderer extends ElementRenderer { + static matchesClass( + ceClass: typeof HTMLElement, + tagName: string, + attributes: Map + ) { + if (tagNames.includes(tagName)) { + return false; + } + return renderer.matchesClass(ceClass, tagName, attributes); + } + }; +}; + export const handleCatalogRoute = async ( context: ParameterizedContext< DefaultState, @@ -25,11 +49,18 @@ export const handleCatalogRoute = async ( globalThis.location = new URL(context.URL.href) as unknown as Location; context.body = Readable.from( - renderPage({ - title: `Web Components Catalog`, - initScript: '/js/catalog/boot.js', - content: html``, - }) + renderPage( + { + title: `Web Components Catalog`, + initScript: '/js/catalog/boot.js', + content: html``, + }, + { + elementRenderers: [ + excludeElements(LitElementRenderer, ['md-outlined-text-field']), + ], + } + ) ); context.type = 'html'; context.status = 200; diff --git a/packages/site-templates/src/base.ts b/packages/site-templates/src/base.ts index dd2500d3..031c4c54 100644 --- a/packages/site-templates/src/base.ts +++ b/packages/site-templates/src/base.ts @@ -14,6 +14,15 @@ import {escapeHTML} from './escape-html.js'; export {unsafeHTML} from 'lit/directives/unsafe-html.js'; export {html} from 'lit'; +// These are needed to load MWC components +// See https://github.com/material-components/material-web/issues/3733 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).FormData = class FormData {}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).FormDataEvent = class FormDataEvent extends Event {}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).HTMLInputElement = class HTMLInputElement {}; + export function* renderPage( data: { scripts?: Array; From c729f4e4e3b648b639f7ddb75d1bf447cfe42657 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Tue, 17 Jan 2023 23:49:22 -0800 Subject: [PATCH 2/6] Add back ElementRenderer methods --- .../src/lib/catalog/routes/catalog-page.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/site-server/src/lib/catalog/routes/catalog-page.ts b/packages/site-server/src/lib/catalog/routes/catalog-page.ts index 8b21a9ac..2eccd2e8 100644 --- a/packages/site-server/src/lib/catalog/routes/catalog-page.ts +++ b/packages/site-server/src/lib/catalog/routes/catalog-page.ts @@ -18,6 +18,7 @@ import { } from '@lit-labs/ssr/lib/element-renderer.js'; import '@webcomponents/internal-site-client/lib/pages/catalog/wco-catalog-page.js'; +import {RenderInfo, RenderResult} from '@lit-labs/ssr'; const excludeElements = ( renderer: ElementRendererConstructor, @@ -34,6 +35,33 @@ const excludeElements = ( } return renderer.matchesClass(ceClass, tagName, attributes); } + + connectedCallback() { + // do nothing + } + + attributeChangedCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _name: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _old: string | null, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _value: string | null + ) { + // do nothing + } + + get shadowRootOptions() { + return {mode: 'open' as const}; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + renderShadow(_renderInfo: RenderInfo): RenderResult | undefined { + return undefined; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + renderLight(_renderInfo: RenderInfo): RenderResult | undefined { + return undefined; + } }; }; From 53d3c31398ab7a26f1ad641f5ff8d9660efde6bd Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Wed, 18 Jan 2023 10:07:41 -0800 Subject: [PATCH 3/6] Add development export condition --- packages/site-server/src/lib/dev-server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/site-server/src/lib/dev-server.ts b/packages/site-server/src/lib/dev-server.ts index 41ab6466..422d0046 100644 --- a/packages/site-server/src/lib/dev-server.ts +++ b/packages/site-server/src/lib/dev-server.ts @@ -29,6 +29,8 @@ startDevServer({ router.routes() as Middleware, ], watch: true, - nodeResolve: true, + nodeResolve: { + exportConditions: ['development'], + }, }, }); From a538e8de5cdd58c2e3020412de32726480523d4e Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Wed, 18 Jan 2023 10:07:59 -0800 Subject: [PATCH 4/6] Fix excludeElements --- .../src/lib/catalog/routes/catalog-page.ts | 61 ++++++++----------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/packages/site-server/src/lib/catalog/routes/catalog-page.ts b/packages/site-server/src/lib/catalog/routes/catalog-page.ts index 2eccd2e8..3c190d2a 100644 --- a/packages/site-server/src/lib/catalog/routes/catalog-page.ts +++ b/packages/site-server/src/lib/catalog/routes/catalog-page.ts @@ -12,56 +12,43 @@ import {Readable} from 'stream'; import Router from '@koa/router'; import {LitElementRenderer} from '@lit-labs/ssr/lib/lit-element-renderer.js'; -import { - ElementRenderer, - ElementRendererConstructor, -} from '@lit-labs/ssr/lib/element-renderer.js'; +import {ElementRenderer} from '@lit-labs/ssr/lib/element-renderer.js'; import '@webcomponents/internal-site-client/lib/pages/catalog/wco-catalog-page.js'; -import {RenderInfo, RenderResult} from '@lit-labs/ssr'; +type Interface = { + [P in keyof T]: T[P]; +}; + +// TODO (justinfagnani): Update Lit SSR to use this type for +// ElementRendererConstructor +export type ElementRendererConstructor = (new ( + tagName: string +) => Interface) & + typeof ElementRenderer; + +// Excludes the given tag names from being handled by the given renderer. +// Returns a subclass of the renderer that returns `false` for matches() +// for any element in the list of tag names. const excludeElements = ( renderer: ElementRendererConstructor, - tagNames: Array + excludedTagNames: Array ) => { - return class ExcludeElementRenderer extends ElementRenderer { + return class ExcludeElementRenderer extends renderer { static matchesClass( ceClass: typeof HTMLElement, tagName: string, attributes: Map ) { - if (tagNames.includes(tagName)) { - return false; - } - return renderer.matchesClass(ceClass, tagName, attributes); - } - - connectedCallback() { - // do nothing - } - - attributeChangedCallback( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _name: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _old: string | null, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _value: string | null - ) { - // do nothing + return excludedTagNames.includes(tagName) + ? false + : super.matchesClass(ceClass, tagName, attributes); } - get shadowRootOptions() { - return {mode: 'open' as const}; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - renderShadow(_renderInfo: RenderInfo): RenderResult | undefined { - return undefined; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - renderLight(_renderInfo: RenderInfo): RenderResult | undefined { - return undefined; - } + // This tells SSR to not render a shadow root. + // renderShadow _can_ be undefined in lit-html's render-value.ts + // We should make it optional in ElementRenderer. + renderShadow = undefined as unknown as ElementRenderer['renderShadow']; }; }; From e0f0946f27ef5fc9cbc6860a73880e279915f54f Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Wed, 18 Jan 2023 10:47:43 -0800 Subject: [PATCH 5/6] format --- packages/site-server/src/lib/dev-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/site-server/src/lib/dev-server.ts b/packages/site-server/src/lib/dev-server.ts index 422d0046..b67d0ec3 100644 --- a/packages/site-server/src/lib/dev-server.ts +++ b/packages/site-server/src/lib/dev-server.ts @@ -31,6 +31,6 @@ startDevServer({ watch: true, nodeResolve: { exportConditions: ['development'], - }, + }, }, }); From a8d80b74b7b8c0ef90b72d0d91eb0de893fab746 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Wed, 18 Jan 2023 11:00:14 -0800 Subject: [PATCH 6/6] Add a fallback renderer that doesn't render a shadow root --- .../src/lib/catalog/routes/catalog-page.ts | 69 +++++++++++++++++-- .../src/test/catalog/search_test.ts | 9 +++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/site-server/src/lib/catalog/routes/catalog-page.ts b/packages/site-server/src/lib/catalog/routes/catalog-page.ts index 3c190d2a..7624f7dc 100644 --- a/packages/site-server/src/lib/catalog/routes/catalog-page.ts +++ b/packages/site-server/src/lib/catalog/routes/catalog-page.ts @@ -40,18 +40,76 @@ const excludeElements = ( tagName: string, attributes: Map ) { + console.log('matchesClass', tagName, !excludedTagNames.includes(tagName)); return excludedTagNames.includes(tagName) ? false : super.matchesClass(ceClass, tagName, attributes); } - - // This tells SSR to not render a shadow root. - // renderShadow _can_ be undefined in lit-html's render-value.ts - // We should make it optional in ElementRenderer. - renderShadow = undefined as unknown as ElementRenderer['renderShadow']; }; }; +const replacements = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + // Note ' was not defined in the HTML4 spec, and is not supported by very + // old browsers like IE8, so a codepoint entity is used instead. + "'": ''', +}; + +/** + * Replaces characters which have special meaning in HTML (&<>"') with escaped + * HTML entities ("&", "<", etc.). + */ +const escapeHtml = (str: string) => + str.replace( + /[&<>"']/g, + (char) => replacements[char as keyof typeof replacements] + ); + +// The built-in FallbackRenderer incorrectly causes a +// shadow root to be rendered, which breaks hydration +class FallbackRenderer extends ElementRenderer { + static matchesClass( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ceClass: typeof HTMLElement, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _tagName: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _attributes: Map + ) { + return true; + } + private readonly _attributes: {[name: string]: string} = {}; + + override setAttribute(name: string, value: string) { + this._attributes[name] = value; + } + + override *renderAttributes() { + for (const [name, value] of Object.entries(this._attributes)) { + if (value === '' || value === undefined || value === null) { + yield ` ${name}`; + } else { + yield ` ${name}="${escapeHtml(value)}"`; + } + } + } + + connectedCallback() { + // do nothing + } + attributeChangedCallback() { + // do nothing + } + *renderLight() { + // do nothing + } + + declare renderShadow: ElementRenderer['renderShadow']; +} + export const handleCatalogRoute = async ( context: ParameterizedContext< DefaultState, @@ -73,6 +131,7 @@ export const handleCatalogRoute = async ( { elementRenderers: [ excludeElements(LitElementRenderer, ['md-outlined-text-field']), + FallbackRenderer, ], } ) diff --git a/packages/site-server/src/test/catalog/search_test.ts b/packages/site-server/src/test/catalog/search_test.ts index acdc6298..e0fe2c58 100644 --- a/packages/site-server/src/test/catalog/search_test.ts +++ b/packages/site-server/src/test/catalog/search_test.ts @@ -34,4 +34,13 @@ test('Finds a button', async () => { assert.ok(result.elements.length > 2); }); +test('Search page SSRs', async () => { + const response = await request('/catalog'); + assert.equal(response.status, 200); + const result = await response.text(); + + // If the page SSR's, we'll have declarative shadow roots in it. + assert.match(result, '