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);