From b95c86e5ec0e2f6de63a23409b9ec489edb61b86 Mon Sep 17 00:00:00 2001 From: Caleb Williams Date: Mon, 20 Mar 2023 21:33:27 -0500 Subject: [PATCH] [labs/ssr-dom-shim] Add basic support for element internals (#3677) Adds basic `ElementInternals` shim to the labs/ssr-dom-shim package, and `HTMLElement` shim now has an `attachInternals()` method that uses it. `LitElementRenderer` in labs/ssr package will look for any attached internals object and, if so, will reflect any ARIA properties set there to attributes during SSR of the element. These attributes are removed during hydration of the Lit element, to favor the actual `ElementInternals` object. --------- Co-authored-by: Augustine Kim --- .changeset/late-hairs-flash.md | 5 + .changeset/rich-toes-jam.md | 7 + .eslintignore | 1 + .prettierignore | 1 + package-lock.json | 48 +++--- packages/labs/ssr-dom-shim/.gitignore | 1 + packages/labs/ssr-dom-shim/package.json | 4 +- packages/labs/ssr-dom-shim/src/index.ts | 30 +++- .../ssr-dom-shim/src/lib/element-internals.ts | 154 ++++++++++++++++++ .../labs/ssr/src/lib/lit-element-renderer.ts | 27 +++ .../ssr/src/test/integration/client/setup.ts | 5 +- .../ssr/src/test/integration/tests/basic.ts | 81 +++++++++ .../src/test/integration/tests/ssr-test.ts | 1 + packages/lit-element/package.json | 1 + .../src/experimental-hydrate-support.ts | 12 ++ 15 files changed, 344 insertions(+), 34 deletions(-) create mode 100644 .changeset/late-hairs-flash.md create mode 100644 .changeset/rich-toes-jam.md create mode 100644 packages/labs/ssr-dom-shim/src/lib/element-internals.ts diff --git a/.changeset/late-hairs-flash.md b/.changeset/late-hairs-flash.md new file mode 100644 index 0000000000..4d92a6d51b --- /dev/null +++ b/.changeset/late-hairs-flash.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/ssr-dom-shim': minor +--- + +Add rough support for HTMLElement.prototype.attachInternals diff --git a/.changeset/rich-toes-jam.md b/.changeset/rich-toes-jam.md new file mode 100644 index 0000000000..7791380c40 --- /dev/null +++ b/.changeset/rich-toes-jam.md @@ -0,0 +1,7 @@ +--- +'lit': minor +'lit-element': minor +'@lit-labs/ssr': minor +--- + +Reflect ARIA attributes onto server rendered Lit elements with attached internals during SSR and remove them upon hydration. diff --git a/.eslintignore b/.eslintignore index 2723fcc3b3..6a45bbf03f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -278,6 +278,7 @@ packages/labs/ssr-client/node_modules/ packages/labs/ssr-client/index.* packages/labs/ssr-dom-shim/index.* +packages/labs/ssr-dom-shim/lib/ packages/labs/ssr-react/node/ packages/labs/ssr-react/lib/ diff --git a/.prettierignore b/.prettierignore index 71b9b9059c..6fde3fa83c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -265,6 +265,7 @@ packages/labs/ssr-client/node_modules/ packages/labs/ssr-client/index.* packages/labs/ssr-dom-shim/index.* +packages/labs/ssr-dom-shim/lib/ packages/labs/ssr-react/node/ packages/labs/ssr-react/lib/ diff --git a/package-lock.json b/package-lock.json index f9e12513b3..086a33ccf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17498,23 +17498,6 @@ "semver": "bin/semver" } }, - "node_modules/node-fetch": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.0.tgz", - "integrity": "sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.9.tgz", @@ -25646,7 +25629,7 @@ "version": "0.0.0", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/ssr": "^3.0.0", + "@lit-labs/ssr": "^3.0.1", "lit": "^2.6.1" }, "devDependencies": { @@ -25802,6 +25785,7 @@ "version": "3.2.2", "license": "BSD-3-Clause", "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0", "@lit/reactive-element": "^1.3.0", "lit-html": "^2.2.0" }, @@ -28309,6 +28293,21 @@ "parse5": "^7.1.1" }, "dependencies": { + "entities": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", + "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==" + }, + "node-fetch": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.0.tgz", + "integrity": "sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==", + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + }, "parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -28334,7 +28333,7 @@ "@lit-labs/ssr-react": { "version": "file:packages/labs/ssr-react", "requires": { - "@lit-labs/ssr": "^3.0.0", + "@lit-labs/ssr": "^3.0.1", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "lit": "^2.6.1", @@ -38791,6 +38790,7 @@ "version": "file:packages/lit-element", "requires": { "@lit-internal/scripts": "^1.0.0", + "@lit-labs/ssr-dom-shim": "^1.0.0", "@lit-labs/testing": "^0.2.0", "@lit/reactive-element": "^1.3.0", "@webcomponents/shadycss": "^1.8.0", @@ -40059,16 +40059,6 @@ } } }, - "node-fetch": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.0.tgz", - "integrity": "sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==", - "requires": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - } - }, "node-releases": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.9.tgz", diff --git a/packages/labs/ssr-dom-shim/.gitignore b/packages/labs/ssr-dom-shim/.gitignore index df5b8b07eb..fe472c3de1 100644 --- a/packages/labs/ssr-dom-shim/.gitignore +++ b/packages/labs/ssr-dom-shim/.gitignore @@ -1 +1,2 @@ /index.* +/lib/ diff --git a/packages/labs/ssr-dom-shim/package.json b/packages/labs/ssr-dom-shim/package.json index 4e6fe5fd8a..a2ef837c4d 100644 --- a/packages/labs/ssr-dom-shim/package.json +++ b/packages/labs/ssr-dom-shim/package.json @@ -23,7 +23,8 @@ } }, "files": [ - "index.{d.ts,d.ts.map,js,js.map}" + "index.{d.ts,d.ts.map,js,js.map}", + "lib/" ], "scripts": { "build": "wireit", @@ -43,6 +44,7 @@ "tsconfig.json" ], "output": [ + "lib/", "index.{d.ts,d.ts.map,js,js.map}", "tsconfig.tsbuildinfo" ] diff --git a/packages/labs/ssr-dom-shim/src/index.ts b/packages/labs/ssr-dom-shim/src/index.ts index a8060af392..82ba365ee6 100644 --- a/packages/labs/ssr-dom-shim/src/index.ts +++ b/packages/labs/ssr-dom-shim/src/index.ts @@ -3,6 +3,13 @@ * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ +import {ElementInternalsShim} from './lib/element-internals.js'; + +export { + ariaMixinAttributes, + ElementInternals, + HYDRATE_INTERNALS_ATTR_PREFIX, +} from './lib/element-internals.js'; const attributes: WeakMap< InstanceType, @@ -29,7 +36,6 @@ const attributesForElement = ( // `const ElementShimWithRealType = ElementShim as object as typeof Element;`. // 4. We want the exported names to match the real ones, hence e.g. // `export {ElementShimWithRealType as Element}`. - const ElementShim = class Element { get attributes() { return Array.from(attributesForElement(this)).map(([name, value]) => ({ @@ -37,11 +43,17 @@ const ElementShim = class Element { value, })); } - private __shadowRoot: null | ShadowRoot = null; + private __shadowRootMode: null | ShadowRootMode = null; + protected __shadowRoot: null | ShadowRoot = null; + protected __internals: null | ElementInternals = null; + get shadowRoot() { + if (this.__shadowRootMode === 'closed') { + return null; + } return this.__shadowRoot; } - setAttribute(name: string, value: unknown) { + setAttribute(name: string, value: unknown): void { // Emulate browser behavior that silently casts all values to string. E.g. // `42` becomes `"42"` and `{}` becomes `"[object Object]""`. attributesForElement(this).set(name, String(value)); @@ -54,11 +66,23 @@ const ElementShim = class Element { } attachShadow(init: ShadowRootInit): ShadowRoot { const shadowRoot = {host: this} as object as ShadowRoot; + this.__shadowRootMode = init.mode; if (init && init.mode === 'open') { this.__shadowRoot = shadowRoot; } return shadowRoot; } + attachInternals(): ElementInternals { + if (this.__internals !== null) { + throw new Error( + `Failed to execute 'attachInternals' on 'HTMLElement': ` + + `ElementInternals for the specified element was already attached.` + ); + } + const internals = new ElementInternalsShim(this as unknown as HTMLElement); + this.__internals = internals; + return internals as ElementInternals; + } getAttribute(name: string) { const value = attributesForElement(this).get(name); return value ?? null; diff --git a/packages/labs/ssr-dom-shim/src/lib/element-internals.ts b/packages/labs/ssr-dom-shim/src/lib/element-internals.ts new file mode 100644 index 0000000000..dd9067ac86 --- /dev/null +++ b/packages/labs/ssr-dom-shim/src/lib/element-internals.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +// As of TypeScript 4.7.4, `ARIAMixin` is missing the following properties +// https://w3c.github.io/aria/#state_prop_def +declare global { + interface ARIAMixin { + ariaBraileLabel: string | null; + ariaBraileRoleDescription: string | null; + ariaDescription: string | null; + ariaInvalid: string | null; + role: string | null; + } +} + +type ARIAAttributeMap = { + [K in keyof ARIAMixin]: string; +}; + +/** + * Map of ARIAMixin properties to attributes + */ +export const ariaMixinAttributes: ARIAAttributeMap = { + ariaAtomic: 'aria-atomic', + ariaAutoComplete: 'aria-autocomplete', + ariaBraileLabel: 'aria-brailelabel', + ariaBraileRoleDescription: 'aria-braileroledescription', + ariaBusy: 'aria-busy', + ariaChecked: 'aria-checked', + ariaColCount: 'aria-colcount', + ariaColIndex: 'aria-colindex', + ariaColSpan: 'aria-colspan', + ariaCurrent: 'aria-current', + ariaDescription: 'aria-description', + ariaDisabled: 'aria-disabled', + ariaExpanded: 'aria-expanded', + ariaHasPopup: 'aria-haspopup', + ariaHidden: 'aria-hidden', + ariaInvalid: 'aria-invalid', + ariaKeyShortcuts: 'aria-keyshortcuts', + ariaLabel: 'aria-label', + ariaLevel: 'aria-level', + ariaLive: 'aria-live', + ariaModal: 'aria-modal', + ariaMultiLine: 'aria-multiline', + ariaMultiSelectable: 'aria-multiselectable', + ariaOrientation: 'aria-orientation', + ariaPlaceholder: 'aria-placeholder', + ariaPosInSet: 'aria-posinset', + ariaPressed: 'aria-pressed', + ariaReadOnly: 'aria-readonly', + ariaRequired: 'aria-required', + ariaRoleDescription: 'aria-roledescription', + ariaRowCount: 'aria-rowcount', + ariaRowIndex: 'aria-rowindex', + ariaRowSpan: 'aria-rowspan', + ariaSelected: 'aria-selected', + ariaSetSize: 'aria-setsize', + ariaSort: 'aria-sort', + ariaValueMax: 'aria-valuemax', + ariaValueMin: 'aria-valuemin', + ariaValueNow: 'aria-valuenow', + ariaValueText: 'aria-valuetext', + role: 'role', +}; + +// Shim the global element internals object +// Methods should be fine as noops and properties can generally +// be while on the server. +export const ElementInternalsShim = class ElementInternals + implements ARIAMixin +{ + ariaAtomic = ''; + ariaAutoComplete = ''; + ariaBraileLabel = ''; + ariaBraileRoleDescription = ''; + ariaBusy = ''; + ariaChecked = ''; + ariaColCount = ''; + ariaColIndex = ''; + ariaColSpan = ''; + ariaCurrent = ''; + ariaDescription = ''; + ariaDisabled = ''; + ariaExpanded = ''; + ariaHasPopup = ''; + ariaHidden = ''; + ariaInvalid = ''; + ariaKeyShortcuts = ''; + ariaLabel = ''; + ariaLevel = ''; + ariaLive = ''; + ariaModal = ''; + ariaMultiLine = ''; + ariaMultiSelectable = ''; + ariaOrientation = ''; + ariaPlaceholder = ''; + ariaPosInSet = ''; + ariaPressed = ''; + ariaReadOnly = ''; + ariaRequired = ''; + ariaRoleDescription = ''; + ariaRowCount = ''; + ariaRowIndex = ''; + ariaRowSpan = ''; + ariaSelected = ''; + ariaSetSize = ''; + ariaSort = ''; + ariaValueMax = ''; + ariaValueMin = ''; + ariaValueNow = ''; + ariaValueText = ''; + role = ''; + __host: HTMLElement; + get shadowRoot() { + // Grab the shadow root instance from the Element shim + // to ensure that the shadow root is always available + // to the internals instance even if the mode is 'closed' + return (this.__host as HTMLElement & {__shadowRoot: ShadowRoot}) + .__shadowRoot; + } + constructor(_host: HTMLElement) { + this.__host = _host; + } + checkValidity() { + // TODO(augustjk) Consider actually implementing logic. + // See https://github.com/lit/lit/issues/3740 + console.warn( + '`ElementInternals.checkValidity()` was called on the server.' + + 'This method always returns true.' + ); + return true; + } + form = null; + labels = [] as unknown as NodeListOf; + reportValidity() { + return true; + } + setFormValue(): void {} + setValidity(): void {} + states = new Set(); + validationMessage = ''; + validity = {} as ValidityState; + willValidate = true; +}; + +const ElementInternalsShimWithRealType = + ElementInternalsShim as object as typeof ElementInternals; +export {ElementInternalsShimWithRealType as ElementInternals}; + +export const HYDRATE_INTERNALS_ATTR_PREFIX = 'hydrate-internals-'; diff --git a/packages/labs/ssr/src/lib/lit-element-renderer.ts b/packages/labs/ssr/src/lib/lit-element-renderer.ts index 7ada0887eb..37d34c81d5 100644 --- a/packages/labs/ssr/src/lib/lit-element-renderer.ts +++ b/packages/labs/ssr/src/lib/lit-element-renderer.ts @@ -7,6 +7,10 @@ import {ElementRenderer} from './element-renderer.js'; import {LitElement, CSSResult, ReactiveElement} from 'lit'; import {_$LE} from 'lit-element/private-ssr-support.js'; +import { + ariaMixinAttributes, + HYDRATE_INTERNALS_ATTR_PREFIX, +} from '@lit-labs/ssr-dom-shim'; import {renderValue} from './render-value.js'; import type {RenderInfo} from './render-value.js'; import type {RenderResult} from './render-result.js'; @@ -29,6 +33,29 @@ export class LitElementRenderer extends ElementRenderer { constructor(tagName: string) { super(tagName); this.element = new (customElements.get(this.tagName)!)() as LitElement; + + // Reflect internals AOM attributes back to the DOM prior to hydration to + // ensure search bots can accurately parse element semantics prior to + // hydration. This is called whenever an instance of ElementInternals is + // created on an element to wire up the getters/setters for the ARIAMixin + // properties. + const internals = ( + this.element as object as {__internals: ElementInternals} + ).__internals; + if (internals) { + for (const [ariaProp, ariaAttribute] of Object.entries( + ariaMixinAttributes + )) { + const value = internals[ariaProp as keyof ARIAMixin]; + if (value && !this.element.hasAttribute(ariaAttribute)) { + this.element.setAttribute(ariaAttribute, value); + this.element.setAttribute( + `${HYDRATE_INTERNALS_ATTR_PREFIX}${ariaAttribute}`, + value + ); + } + } + } } override get shadowRootOptions() { diff --git a/packages/labs/ssr/src/test/integration/client/setup.ts b/packages/labs/ssr/src/test/integration/client/setup.ts index 8c2e6fd75e..445984a0a2 100644 --- a/packages/labs/ssr/src/test/integration/client/setup.ts +++ b/packages/labs/ssr/src/test/integration/client/setup.ts @@ -230,6 +230,7 @@ export const setupTest = async ( expectMutationsOnFirstRender, expectMutationsDuringHydration, expectMutationsDuringUpgrade, + skipPreHydrationAssertHtml, } = testSetup; const testFn = @@ -257,7 +258,9 @@ export const setupTest = async ( // The first expectation args are used in the server render. Check the DOM // pre-hydration to make sure they're correct. The DOM is changed again // against the first expectation after hydration in the loop below. - assertHTML(container, expectations[0].html); + if (!skipPreHydrationAssertHtml) { + assertHTML(container, expectations[0].html); + } const stableNodes = stableSelectors.map((selector) => container.querySelector(selector) ); diff --git a/packages/labs/ssr/src/test/integration/tests/basic.ts b/packages/labs/ssr/src/test/integration/tests/basic.ts index bdd83e3d45..a827aa22c4 100644 --- a/packages/labs/ssr/src/test/integration/tests/basic.ts +++ b/packages/labs/ssr/src/test/integration/tests/basic.ts @@ -5185,4 +5185,85 @@ export const tests: {[name: string]: SSRTest} = { stableSelectors: ['le-defer'], }; }, + + 'LitElement: ElementInternals': () => { + return { + registerElements() { + class LEInternals extends LitElement { + constructor() { + super(); + const internals = this.attachInternals() as ElementInternals & { + role: string; + }; + internals.role = 'widget'; + } + } + customElements.define('le-internals', LEInternals); + }, + render() { + return html``; + }, + serverRenderOptions: { + deferHydration: true, + }, + expectations: [ + { + args: [], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-internals') as LitElement; + assert.equal(el.getAttribute('role'), 'widget'); + }, + html: { + root: ``, + 'le-internals': ``, + }, + }, + ], + stableSelectors: ['le-internals'], + }; + }, + + 'LitElement: ElementInternals with hydration': () => { + return { + registerElements() { + class LEInternalsHydrate extends LitElement { + internals; + constructor() { + super(); + const internals = this.attachInternals() as ElementInternals & { + role: string; + }; + internals.role = 'widget'; + this.internals = internals; + } + } + customElements.define('le-internals-hydrate', LEInternalsHydrate); + }, + render() { + return html``; + }, + serverRenderOptions: { + deferHydration: false, + }, + expectations: [ + { + args: [], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector( + 'le-internals-hydrate' + ) as LitElement & {internals: {role: string}}; + assert.isFalse(el.hasAttribute('role')); + }, + html: { + root: ``, + 'le-internals-hydrate': ``, + }, + }, + ], + expectMutationsDuringHydration: true, + expectMutationsDuringUpgrade: true, + skipPreHydrationAssertHtml: true, + stableSelectors: ['le-internals-hydrate'], + }; + }, }; diff --git a/packages/labs/ssr/src/test/integration/tests/ssr-test.ts b/packages/labs/ssr/src/test/integration/tests/ssr-test.ts index 3be9860e59..461a632746 100644 --- a/packages/labs/ssr/src/test/integration/tests/ssr-test.ts +++ b/packages/labs/ssr/src/test/integration/tests/ssr-test.ts @@ -37,6 +37,7 @@ export interface SSRTestDescription { expectMutationsOnFirstRender?: boolean; expectMutationsDuringHydration?: boolean; expectMutationsDuringUpgrade?: boolean; + skipPreHydrationAssertHtml?: boolean; skip?: boolean; only?: boolean; registerElements?(): void | Promise; diff --git a/packages/lit-element/package.json b/packages/lit-element/package.json index 17bcfc44f3..17b93b454b 100644 --- a/packages/lit-element/package.json +++ b/packages/lit-element/package.json @@ -255,6 +255,7 @@ "!/development/test/" ], "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0", "@lit/reactive-element": "^1.3.0", "lit-html": "^2.2.0" }, diff --git a/packages/lit-element/src/experimental-hydrate-support.ts b/packages/lit-element/src/experimental-hydrate-support.ts index 0dc785fb40..c365aa525d 100644 --- a/packages/lit-element/src/experimental-hydrate-support.ts +++ b/packages/lit-element/src/experimental-hydrate-support.ts @@ -13,6 +13,7 @@ import type {PropertyValues} from '@lit/reactive-element'; import {render, RenderOptions} from 'lit-html'; import {hydrate} from 'lit-html/experimental-hydrate.js'; +import {HYDRATE_INTERNALS_ATTR_PREFIX} from '@lit-labs/ssr-dom-shim'; interface PatchableLitElement extends HTMLElement { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-misused-new @@ -95,6 +96,17 @@ globalThis.litElementHydrateSupport = ({ update.call(this, changedProperties); if (this._$needsHydration) { this._$needsHydration = false; + // Remove aria attributes added by internals shim during SSR + for (let i = 0; i < this.attributes.length; i++) { + const attr = this.attributes[i]; + if (attr.name.startsWith(HYDRATE_INTERNALS_ATTR_PREFIX)) { + const ariaAttr = attr.name.slice( + HYDRATE_INTERNALS_ATTR_PREFIX.length + ); + this.removeAttribute(ariaAttr); + this.removeAttribute(attr.name); + } + } hydrate(value, this.renderRoot, this.renderOptions); } else { render(value, this.renderRoot, this.renderOptions);