diff --git a/.changeset/pretty-goats-switch.md b/.changeset/pretty-goats-switch.md new file mode 100644 index 0000000000..4c2bbb09ce --- /dev/null +++ b/.changeset/pretty-goats-switch.md @@ -0,0 +1,9 @@ +--- +'@lit-labs/eleventy-plugin-lit': patch +'@lit-labs/ssr-react': patch +'@lit-labs/testing': patch +'@lit-labs/nextjs': patch +'@lit-labs/ssr': patch +--- + +Use hydration modules from `@lit-labs/ssr-client` diff --git a/.changeset/sweet-lemons-brake.md b/.changeset/sweet-lemons-brake.md new file mode 100644 index 0000000000..533a08ea90 --- /dev/null +++ b/.changeset/sweet-lemons-brake.md @@ -0,0 +1,10 @@ +--- +'@lit-labs/ssr-client': minor +'lit-element': patch +'lit-html': patch +'lit': patch +--- + +`lit-html/experimental-hydrate.js` and `lit-element/experimental-hydrate-support.js` have been moved to `@lit-labs/ssr-client`. + +The modules in the original location have been marked deprecated and will be removed in a future version. diff --git a/.eslintignore b/.eslintignore index 82edcd40a5..00c41adaa9 100644 --- a/.eslintignore +++ b/.eslintignore @@ -275,9 +275,11 @@ packages/labs/ssr/index.* packages/labs/ssr-client/development/ packages/labs/ssr-client/directives/ -packages/labs/ssr-client/controllers/ +packages/labs/ssr-client/lib/ +packages/labs/ssr-client/node/ packages/labs/ssr-client/node_modules/ packages/labs/ssr-client/index.* +packages/labs/ssr-client/lit-element-hydrate-support.* packages/labs/ssr-dom-shim/index.* packages/labs/ssr-dom-shim/lib/ diff --git a/.prettierignore b/.prettierignore index 23da6b0e2d..ee9c6902f8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -262,9 +262,11 @@ packages/labs/ssr/index.* packages/labs/ssr-client/development/ packages/labs/ssr-client/directives/ -packages/labs/ssr-client/controllers/ +packages/labs/ssr-client/lib/ +packages/labs/ssr-client/node/ packages/labs/ssr-client/node_modules/ packages/labs/ssr-client/index.* +packages/labs/ssr-client/lit-element-hydrate-support.* packages/labs/ssr-dom-shim/index.* packages/labs/ssr-dom-shim/lib/ diff --git a/package-lock.json b/package-lock.json index fd74f89490..729036729f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25660,6 +25660,7 @@ "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr": "^3.1.0", + "@lit-labs/ssr-client": "^1.0.1", "lit": "^2.7.0" }, "devDependencies": { @@ -25733,6 +25734,7 @@ "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr": "^3.0.0", + "@lit-labs/ssr-client": "^1.0.1", "@web/test-runner-commands": "^0.6.1", "@webcomponents/template-shadowroot": "^0.1.0", "lit": "^2.6.0" @@ -28321,6 +28323,7 @@ "version": "file:packages/labs/ssr-react", "requires": { "@lit-labs/ssr": "^3.1.0", + "@lit-labs/ssr-client": "^1.0.1", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "lit": "^2.7.0", @@ -28363,6 +28366,7 @@ "version": "file:packages/labs/testing", "requires": { "@lit-labs/ssr": "^3.0.0", + "@lit-labs/ssr-client": "^1.0.1", "@open-wc/testing": "^3.1.5", "@web/test-runner-commands": "^0.6.1", "@webcomponents/template-shadowroot": "^0.1.0", diff --git a/packages/labs/eleventy-plugin-lit/README.md b/packages/labs/eleventy-plugin-lit/README.md index 677c8d42be..0a4a4d5a4f 100644 --- a/packages/labs/eleventy-plugin-lit/README.md +++ b/packages/labs/eleventy-plugin-lit/README.md @@ -252,7 +252,7 @@ to their JavaScript implementations, becoming responsive and interactive. Lit components can automatically hydrate themselves when they detect that a Shadow Root has already been attached, as long as Lit's _experimental hydrate support_ module has been installed by importing -[`lit/experimental-hydrate-support.js`](https://github.com/lit/lit/blob/main/packages/lit-element/src/experimental-hydrate-support.ts). +[`@lit-labs/ssr-client/lit-element-hydrate-support.js`](https://github.com/lit/lit/blob/main/packages/labs/ssr-client/src/lit-element-hydrate-support.ts). > ⏱️ The Lit hydration support module **must be loaded before Lit or any > components that depend on Lit are imported**, because it modifies the initial @@ -325,7 +325,7 @@ The file `_includes/default.html` would then contain the following: execute them yet, though. --> @@ -368,7 +368,7 @@ The file `_includes/default.html` would then contain the following: // Start fetching the Lit hydration support module (note the absence // of "await" -- we don't want to block yet). const litHydrateSupportInstalled = import( - '/node_modules/lit/experimental-hydrate-support.js' + '/node_modules/@lit-labs/ssr-client/lit-element-hydrate-support.js' ); // Check if we require the declarative shadow DOM polyfill. As of diff --git a/packages/labs/eleventy-plugin-lit/demo/_includes/default.html b/packages/labs/eleventy-plugin-lit/demo/_includes/default.html index 0e51b72e3c..1a67d28890 100644 --- a/packages/labs/eleventy-plugin-lit/demo/_includes/default.html +++ b/packages/labs/eleventy-plugin-lit/demo/_includes/default.html @@ -44,7 +44,7 @@ // Start fetching the Lit hydration support module (note the absence // of "await" -- we don't want to block yet). const litHydrateSupportInstalled = import( - 'lit/experimental-hydrate-support.js' + '@lit-labs/ssr-client/lit-element-hydrate-support.js' ); // Check if we require the declarative shadow DOM polyfill. As of diff --git a/packages/labs/nextjs/src/index.ts b/packages/labs/nextjs/src/index.ts index 723e9af585..43947f28d3 100644 --- a/packages/labs/nextjs/src/index.ts +++ b/packages/labs/nextjs/src/index.ts @@ -28,7 +28,7 @@ export = (_pluginOptions: LitSsrPluginOptions = {}) => options: { // This adds a side-effectful import which monkey patches // `React.createElement` in the server and imports - // `lit/experimental-hydrate-support.js` in the client. + // `@lit-labs/ssr-client/lit-element-hydrate-support.js` in the client. imports: ['side-effects @lit-labs/ssr-react/enable-lit-ssr.js'], }, }); diff --git a/packages/labs/ssr-client/.gitignore b/packages/labs/ssr-client/.gitignore index c46c41aa62..384166957a 100644 --- a/packages/labs/ssr-client/.gitignore +++ b/packages/labs/ssr-client/.gitignore @@ -1,5 +1,7 @@ /development/ /directives/ -/controllers/ +/lib/ +/node/ /node_modules/ /index.* +/lit-element-hydrate-support.* diff --git a/packages/labs/ssr-client/package.json b/packages/labs/ssr-client/package.json index b4ac2ba023..c52705c653 100644 --- a/packages/labs/ssr-client/package.json +++ b/packages/labs/ssr-client/package.json @@ -18,10 +18,25 @@ }, "exports": { ".": { + "types": "./development/index.d.ts", + "node": { + "development": "./node/development/index.js", + "default": "./node/index.js" + }, "development": "./development/index.js", "default": "./index.js" }, + "./lit-element-hydrate-support.js": { + "types": "./development/lit-element-hydrate-support.d.ts", + "node": { + "development": "./node/development/lit-element-hydrate-support.js", + "default": "./node/lit-element-hydrate-support.js" + }, + "development": "./development/lit-element-hydrate-support.js", + "default": "./lit-element-hydrate-support.js" + }, "./directives/render-light.js": { + "types": "./development/directives/render-light.d.ts", "development": "./development/directives/render-light.js", "default": "./directives/render-light.js" } @@ -30,7 +45,9 @@ "/development/", "!/development/test/", "/directives/", - "/index.{d.ts,d.ts.map,js,js.map}" + "/lib/", + "/index.{d.ts,d.ts.map,js,js.map}", + "/lit-element-hydrate-support.{d.ts,d.ts.map,js,js.map}" ], "scripts": { "build": "wireit", @@ -72,7 +89,8 @@ "files": [], "output": [ "*.d.ts{,.map}", - "directives/*.d.ts{,.map}" + "directives/*.d.ts{,.map}", + "lib/*.d.ts{,.map}" ] }, "build:rollup": { @@ -86,7 +104,10 @@ ], "output": [ "index.js{,.map}", - "directives/*.js{,.map}" + "lit-element-hydrate-support.js{,.map}", + "directives/*.js{,.map}", + "lib/*.js{,.map}", + "node/" ] }, "checksize": { @@ -106,9 +127,9 @@ "@lit-internal/scripts": "^1.0.0" }, "dependencies": { + "@lit/reactive-element": "^1.0.0", "lit": "^2.0.0", - "lit-html": "^2.0.0", - "@lit/reactive-element": "^1.0.0" + "lit-html": "^2.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/labs/ssr-client/rollup.config.js b/packages/labs/ssr-client/rollup.config.js index 2d525b169b..ae8b9dcfec 100644 --- a/packages/labs/ssr-client/rollup.config.js +++ b/packages/labs/ssr-client/rollup.config.js @@ -9,6 +9,11 @@ import {createRequire} from 'module'; export default litProdConfig({ packageName: createRequire(import.meta.url)('./package.json').name, - entryPoints: ['index', 'directives/render-light'], + entryPoints: [ + 'index', + 'lit-element-hydrate-support', + 'directives/render-light', + ], external: ['lit/directive.js', 'lit/directive-helpers.js'], + includeNodeBuild: true, }); diff --git a/packages/labs/ssr-client/src/env.d.ts b/packages/labs/ssr-client/src/env.d.ts new file mode 100644 index 0000000000..1687c28220 --- /dev/null +++ b/packages/labs/ssr-client/src/env.d.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +// eslint-disable-next-line no-var +declare var litElementHydrateSupport: + | undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | ((options: {LitElement: any}) => void); diff --git a/packages/labs/ssr-client/src/index.ts b/packages/labs/ssr-client/src/index.ts index f29ae1023e..8a29ae2117 100644 --- a/packages/labs/ssr-client/src/index.ts +++ b/packages/labs/ssr-client/src/index.ts @@ -3,4 +3,4 @@ * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -export {}; +export * from './lib/hydrate-lit-html.js'; diff --git a/packages/labs/ssr-client/src/lib/hydrate-lit-html.ts b/packages/labs/ssr-client/src/lib/hydrate-lit-html.ts new file mode 100644 index 0000000000..210e400ffa --- /dev/null +++ b/packages/labs/ssr-client/src/lib/hydrate-lit-html.ts @@ -0,0 +1,472 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { + DirectiveParent, + RenderOptions, + TemplateResult, + noChange, +} from 'lit-html'; +import {_$LH} from 'lit-html/private-ssr-support.js'; +import { + AttributePart, + AttributePartInfo, + PartType, +} from 'lit-html/directive.js'; +import { + isPrimitive, + isSingleExpression, + isTemplateResult, +} from 'lit-html/directive-helpers.js'; + +// In the Node build, this import will be injected by Rollup: +// import {Buffer} from 'buffer'; + +const NODE_MODE = false; + +const {TemplateInstance, isIterable, resolveDirective, ChildPart, ElementPart} = + _$LH; + +type ChildPart = InstanceType; +type TemplateInstance = InstanceType; + +/** + * Information needed to rehydrate a single TemplateResult. + */ +type ChildPartState = + | { + type: 'leaf'; + /** The ChildPart that the result is rendered to */ + part: ChildPart; + } + | { + type: 'iterable'; + /** The ChildPart that the result is rendered to */ + part: ChildPart; + value: Iterable; + iterator: Iterator; + done: boolean; + } + | { + type: 'template-instance'; + /** The ChildPart that the result is rendered to */ + part: ChildPart; + + result: TemplateResult; + + /** The TemplateInstance created from the TemplateResult */ + instance: TemplateInstance; + + /** + * The index of the next Template part to be hydrated. This is mutable and + * updated as the tree walk discovers new part markers at the right level in + * the template instance tree. Note there is only one Template part per + * attribute with (one or more) bindings. + */ + templatePartIndex: number; + + /** + * The index of the next TemplateInstance part to be hydrated. This is used + * to retrieve the value from the TemplateResult and initialize the + * TemplateInstance parts' values for dirty-checking on first render. + */ + instancePartIndex: number; + }; + +/** + * hydrate() operates on a container with server-side rendered content and + * restores the client side data structures needed for lit-html updates such as + * TemplateInstances and Parts. After calling `hydrate`, lit-html will behave as + * if it initially rendered the DOM, and any subsequent updates will update + * efficiently, the same as if lit-html had rendered the DOM on the client. + * + * hydrate() must be called on DOM that adheres the to lit-ssr structure for + * parts. ChildParts must be represented with both a start and end comment + * marker, and ChildParts that contain a TemplateInstance must have the template + * digest written into the comment data. + * + * Since render() encloses its output in a ChildPart, there must always be a root + * ChildPart. + * + * Example (using for # ... for annotations in HTML) + * + * Given this input: + * + * html`
${y}
` + * + * The SSR DOM is: + * + * # Start marker for the root ChildPart created + * # by render(). Includes the digest of the + * # template + *
+ * # Indicates there are attribute bindings here + * # The number is the depth-first index of the parent + * # node in the template. + * # Start marker for the ${x} expression + * TEST_Y + * # End marker for the ${x} expression + *
+ * + * # End marker for the root ChildPart + * + * @param rootValue + * @param container + * @param userOptions + */ +export const hydrate = ( + rootValue: unknown, + container: Element | DocumentFragment, + options: Partial = {} +) => { + // TODO(kschaaf): Do we need a helper for _$litPart$ ("part for node")? + // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((container as any)['_$litPart$'] !== undefined) { + throw new Error('container already contains a live render'); + } + + // Since render() creates a ChildPart to render into, we'll always have + // exactly one root part. We need to hold a reference to it so we can set + // it in the parts cache. + let rootPart: ChildPart | undefined = undefined; + + // Used for error messages + let rootPartMarker: Comment | undefined = undefined; + + // When we are in-between ChildPart markers, this is the current ChildPart. + // It's needed to be able to set the ChildPart's endNode when we see a + // close marker + let currentChildPart: ChildPart | undefined = undefined; + + // Used to remember parent template state as we recurse into nested + // templates + const stack: Array = []; + + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_COMMENT, + null, + false + ); + let marker: Comment | null; + + // Walk the DOM looking for part marker comments + while ((marker = walker.nextNode() as Comment | null) !== null) { + const markerText = marker.data; + if (markerText.startsWith('lit-part')) { + if (stack.length === 0 && rootPart !== undefined) { + throw new Error( + `There must be only one root part per container. ` + + `Found a part marker (${marker}) when we already have a root ` + + `part marker (${rootPartMarker})` + ); + } + // Create a new ChildPart and push it onto the stack + currentChildPart = openChildPart(rootValue, marker, stack, options); + rootPart ??= currentChildPart; + rootPartMarker ??= marker; + } else if (markerText.startsWith('lit-node')) { + // Create and hydrate attribute parts into the current ChildPart on the + // stack + createAttributeParts(marker, stack, options); + } else if (markerText.startsWith('/lit-part')) { + // Close the current ChildPart, and pop the previous one off the stack + if (stack.length === 1 && currentChildPart !== rootPart) { + throw new Error('internal error'); + } + currentChildPart = closeChildPart(marker, currentChildPart, stack); + } + } + if (rootPart === undefined) { + const elementMessage = + container instanceof ShadowRoot + ? `{container.host.localName}'s shadow root` + : container instanceof DocumentFragment + ? 'DocumentFragment' + : container.localName; + console.error( + `There should be exactly one root part in a render container, ` + + `but we didn't find any in ${elementMessage}.` + ); + } // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (container as any)['_$litPart$'] = rootPart; +}; + +const openChildPart = ( + rootValue: unknown, + marker: Comment, + stack: Array, + options: RenderOptions +) => { + let value: unknown; + // We know the startNode now. We'll know the endNode when we get to + // the matching marker and set it in closeChildPart() + // TODO(kschaaf): Current constructor takes both nodes + let part; + if (stack.length === 0) { + part = new ChildPart(marker, null, undefined, options); + value = rootValue; + } else { + const state = stack[stack.length - 1]; + if (state.type === 'template-instance') { + part = new ChildPart(marker, null, state.instance, options); + state.instance._$parts.push(part); + value = state.result.values[state.instancePartIndex++]; + state.templatePartIndex++; + } else if (state.type === 'iterable') { + part = new ChildPart(marker, null, state.part, options); + const result = state.iterator.next(); + if (result.done) { + value = undefined; + state.done = true; + throw new Error('Unhandled shorter than expected iterable'); + } else { + value = result.value; + } + (state.part._$committedValue as Array).push(part); + } else { + // state.type === 'leaf' + // TODO(kschaaf): This is unexpected, and likely a result of a primitive + // been rendered on the client when a TemplateResult was rendered on the + // server; this part will be hydrated but not used. We can detect it, but + // we need to decide what to do in this case. Note that this part won't be + // retained by any parent TemplateInstance, since a primitive had been + // rendered in its place. + // https://github.com/lit/lit/issues/1434 + // throw new Error('Hydration value mismatch: Found a TemplateInstance' + + // 'where a leaf value was expected'); + part = new ChildPart(marker, null, state.part, options); + } + } + + // Initialize the ChildPart state depending on the type of value and push + // it onto the stack. This logic closely follows the ChildPart commit() + // cascade order: + // 1. directive + // 2. noChange + // 3. primitive (note strings must be handled before iterables, since they + // are iterable) + // 4. TemplateResult + // 5. Node (not yet implemented, but fallback handling is fine) + // 6. Iterable + // 7. nothing (handled in fallback) + // 8. Fallback for everything else + value = resolveDirective(part, value); + if (value === noChange) { + stack.push({part, type: 'leaf'}); + } else if (isPrimitive(value)) { + stack.push({part, type: 'leaf'}); + part._$committedValue = value; + // TODO(kschaaf): We can detect when a primitive is being hydrated on the + // client where a TemplateResult was rendered on the server, but we need to + // decide on a strategy for what to do next. + // https://github.com/lit/lit/issues/1434 + // if (marker.data !== 'lit-part') { + // throw new Error('Hydration value mismatch: Primitive found where TemplateResult expected'); + // } + } else if (isTemplateResult(value)) { + // Check for a template result digest + const markerWithDigest = `lit-part ${digestForTemplateResult(value)}`; + if (marker.data === markerWithDigest) { + const template = ( + ChildPart.prototype as ChildPart & { + _$getTemplate( + value: TemplateResult + ): ConstructorParameters[0]; + } + )._$getTemplate(value); + const instance = new TemplateInstance(template, part); + stack.push({ + type: 'template-instance', + instance, + part, + templatePartIndex: 0, + instancePartIndex: 0, + result: value, + }); + // For TemplateResult values, we set the part value to the + // generated TemplateInstance + part._$committedValue = instance; + } else { + // TODO: if this isn't the server-rendered template, do we + // need to stop hydrating this subtree? Clear it? Add tests. + throw new Error( + 'Hydration value mismatch: Unexpected TemplateResult rendered to part' + ); + } + } else if (isIterable(value)) { + // currentChildPart.value will contain an array of ChildParts + stack.push({ + part: part, + type: 'iterable', + value, + iterator: value[Symbol.iterator](), + done: false, + }); + part._$committedValue = []; + } else { + // Fallback for everything else (nothing, Objects, Functions, + // etc.): we just initialize the part's value + // Note that `Node` value types are not currently supported during + // SSR, so that part of the cascade is missing. + stack.push({part: part, type: 'leaf'}); + part._$committedValue = value == null ? '' : value; + } + return part; +}; + +const closeChildPart = ( + marker: Comment, + part: ChildPart | undefined, + stack: Array +): ChildPart | undefined => { + if (part === undefined) { + throw new Error('unbalanced part marker'); + } + + (part as ChildPart & {_$endNode: ChildNode})._$endNode = marker; + + const currentState = stack.pop()!; + + if (currentState.type === 'iterable') { + if (!currentState.iterator.next().done) { + throw new Error('unexpected longer than expected iterable'); + } + } + + if (stack.length > 0) { + const state = stack[stack.length - 1]; + return state.part; + } else { + return undefined; + } +}; + +const createAttributeParts = ( + comment: Comment, + stack: Array, + options: RenderOptions +) => { + // Get the nodeIndex from DOM. We're only using this for an integrity + // check right now, we might not need it. + const match = /lit-node (\d+)/.exec(comment.data)!; + const nodeIndex = parseInt(match[1]); + + // Node markers are added as a previous sibling to identify elements + // with attribute/property/element/event bindings or custom elements + // whose `defer-hydration` attribute needs to be removed + const node = comment.nextElementSibling; + if (node === null) { + throw new Error('could not find node for attribute parts'); + } + // Remove `defer-hydration` attribute, if any + node.removeAttribute('defer-hydration'); + + const state = stack[stack.length - 1]; + if (state.type === 'template-instance') { + const instance = state.instance; + // eslint-disable-next-line no-constant-condition + while (true) { + // If the next template part is in attribute-position on the current node, + // create the instance part for it and prime its state + const templatePart = instance._$template.parts[state.templatePartIndex]; + if ( + templatePart === undefined || + (templatePart.type !== PartType.ATTRIBUTE && + templatePart.type !== PartType.ELEMENT) || + templatePart.index !== nodeIndex + ) { + break; + } + + if (templatePart.type === PartType.ATTRIBUTE) { + // The instance part is created based on the constructor saved in the + // template part + const instancePart = new templatePart.ctor( + node as HTMLElement, + templatePart.name, + templatePart.strings, + state.instance, + options + ); + + const value = isSingleExpression( + instancePart as unknown as AttributePartInfo + ) + ? state.result.values[state.instancePartIndex] + : state.result.values; + + // Setting the attribute value primes committed value with the resolved + // directive value; we only then commit that value for event/property + // parts since those were not serialized, and pass `noCommit` for the + // others to avoid perf impact of touching the DOM unnecessarily + const noCommit = !( + instancePart.type === PartType.EVENT || + instancePart.type === PartType.PROPERTY + ); + ( + instancePart as AttributePart & { + _$setValue( + value: unknown, + directiveParent: DirectiveParent, + valueIndex?: number, + noCommit?: boolean + ): void; + } + )._$setValue(value, instancePart, state.instancePartIndex, noCommit); + state.instancePartIndex += templatePart.strings.length - 1; + instance._$parts.push(instancePart); + } else { + // templatePart.type === PartType.ELEMENT + const instancePart = new ElementPart(node, state.instance, options); + resolveDirective( + instancePart, + state.result.values[state.instancePartIndex++] + ); + instance._$parts.push(instancePart); + } + state.templatePartIndex++; + } + } else { + throw new Error('internal error'); + } +}; + +// Number of 32 bit elements to use to create template digests +const digestSize = 2; +// We need to specify a digest to use across rendering environments. This is a +// simple digest build from a DJB2-ish hash modified from: +// https://github.com/darkskyapp/string-hash/blob/master/index.js +// It has been changed to an array of hashes to add additional bits. +// Goals: +// - Extremely low collision rate. We may not be able to detect collisions. +// - Extremely fast. +// - Extremely small code size. +// - Safe to include in HTML comment text or attribute value. +// - Easily specifiable and implementable in multiple languages. +// We don't care about cryptographic suitability. +export const digestForTemplateResult = (templateResult: TemplateResult) => { + const hashes = new Uint32Array(digestSize).fill(5381); + + for (const s of templateResult.strings) { + for (let i = 0; i < s.length; i++) { + hashes[i % digestSize] = (hashes[i % digestSize] * 33) ^ s.charCodeAt(i); + } + } + const str = String.fromCharCode(...new Uint8Array(hashes.buffer)); + // Use `btoa` in browsers because it is supported universally. + // + // In Node, we are sometimes executing in an isolated VM context, which means + // neither `btoa` nor `Buffer` will be globally available by default (also + // note that `btoa` is only supported in Node 16+ anyway, and we still support + // Node 14). Instead of requiring users to always provide an implementation + // for `btoa` when they set up their VM context, we instead inject an import + // for `Buffer` from Node's built-in `buffer` module in our Rollup config (see + // note at the top of this file), and use that. + return NODE_MODE ? Buffer.from(str, 'binary').toString('base64') : btoa(str); +}; diff --git a/packages/labs/ssr-client/src/lit-element-hydrate-support.ts b/packages/labs/ssr-client/src/lit-element-hydrate-support.ts new file mode 100644 index 0000000000..b90c446933 --- /dev/null +++ b/packages/labs/ssr-client/src/lit-element-hydrate-support.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * LitElement support for hydration of content rendered using lit-ssr. + * + * @packageDocumentation + */ + +import type {PropertyValues} from '@lit/reactive-element'; +import {render, RenderOptions} from 'lit-html'; +import {hydrate} from './lib/hydrate-lit-html.js'; + +// Keep consistent with `@lit-labs/ssr-dom-shim` +const HYDRATE_INTERNALS_ATTR_PREFIX = 'hydrate-internals-'; + +interface PatchableLitElement extends HTMLElement { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-misused-new + new (...args: any[]): PatchableLitElement; + enableUpdating(requestedUpdate?: boolean): void; + createRenderRoot(): Element | ShadowRoot; + renderRoot: HTMLElement | DocumentFragment; + render(): unknown; + renderOptions: RenderOptions; + _$needsHydration: boolean; +} + +globalThis.litElementHydrateSupport = ({ + LitElement, +}: { + LitElement: PatchableLitElement; +}) => { + const observedAttributes = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(LitElement), + 'observedAttributes' + )!.get!; + + // Add `defer-hydration` to observedAttributes + Object.defineProperty(LitElement, 'observedAttributes', { + get() { + return [...observedAttributes.call(this), 'defer-hydration']; + }, + }); + + // Enable element when 'defer-hydration' attribute is removed by calling the + // super.connectedCallback() + const attributeChangedCallback = + LitElement.prototype.attributeChangedCallback; + LitElement.prototype.attributeChangedCallback = function ( + name: string, + old: string | null, + value: string | null + ) { + if (name === 'defer-hydration' && value === null) { + connectedCallback.call(this); + } + attributeChangedCallback.call(this, name, old, value); + }; + + // Override `connectedCallback` to capture whether we need hydration, and + // defer `super.connectedCallback()` if the 'defer-hydration' attribute is set + const connectedCallback = LitElement.prototype.connectedCallback; + LitElement.prototype.connectedCallback = function ( + this: PatchableLitElement + ) { + // If the outer scope of this element has not yet been hydrated, wait until + // 'defer-hydration' attribute has been removed to enable + if (!this.hasAttribute('defer-hydration')) { + connectedCallback.call(this); + } + }; + + // If we've been server-side rendered, just return `this.shadowRoot`, don't + // call the base implementation, which would also adopt styles (for now) + const createRenderRoot = LitElement.prototype.createRenderRoot; + LitElement.prototype.createRenderRoot = function (this: PatchableLitElement) { + if (this.shadowRoot) { + this._$needsHydration = true; + return this.shadowRoot; + } else { + return createRenderRoot.call(this); + } + }; + + // Hydrate on first update when needed + const update = Object.getPrototypeOf(LitElement.prototype).update; + LitElement.prototype.update = function ( + this: PatchableLitElement, + changedProperties: PropertyValues + ) { + const value = this.render(); + // Since this is a patch, we can't call super.update(), so we capture + // it off the proto chain and call it instead + 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); + } + }; +}; diff --git a/packages/labs/ssr-react/README.md b/packages/labs/ssr-react/README.md index 68cad99c5c..bc33c22fea 100644 --- a/packages/labs/ssr-react/README.md +++ b/packages/labs/ssr-react/README.md @@ -32,7 +32,7 @@ import ReactDOM from 'react-dom'; ... ``` -In the browser environment, this module does not patch `React.createElement()` but instead imports `lit/experimental-hydrate-support.js` which must be imported before the `lit` package to allow hydration of server-rendered Lit elements. +In the browser environment, this module does not patch `React.createElement()` but instead imports `@lit-labs/ssr-client/lit-element-hydrate-support.js` which must be imported before the `lit` package to allow hydration of server-rendered Lit elements. This approach has the advantage of being compatible with Lit components wrapped as React components using the `@lit-labs/react` package, which calls `React.createElement()` directly. It'll also work for any external React components pre-compiled with the classic JSX runtime transform. @@ -57,7 +57,7 @@ You may also set the compiler options to specify the function to use instead of Note that the import line must still be present for every file that contains JSX expressions to transform in the classic runtime mode. -This approach only works for server-rendering custom elements added to the project in JSX expressions. It will not affect any pre-compiled JSX expressions or direct calls to `React.createElement()`. You will also need to manually import the `lit/experimental-hydrate-support.js` to your client JS. For those scenarios, use the [monkey patching](#monkey-patching-reactcreateelement-recommended) approach. +This approach only works for server-rendering custom elements added to the project in JSX expressions. It will not affect any pre-compiled JSX expressions or direct calls to `React.createElement()`. You will also need to manually import the `@lit-labs/ssr-client/lit-element-hydrate-support.js` to your client JS. For those scenarios, use the [monkey patching](#monkey-patching-reactcreateelement-recommended) approach. ### Using the Automatic Runtime JSX Transform @@ -66,7 +66,7 @@ If your project is using the [runtime JSX transform](https://reactjs.org/blog/20 - For Babel: set the [`importSource`](https://babeljs.io/docs/en/babel-preset-react#importsource) option in `@babel/preset-react` to `@lit-labs/ssr-react`. - For TypeScript: set the [`jsxImportSource`](https://www.typescriptlang.org/tsconfig#jsxImportSource) option in `tsconfig.json` to `@lit-labs/ssr-react`. -These JSX runtime modules contain jsx functions enhanced to add the declarative shadow DOM output to registered custom elements when imported into server environemtns. They also automatically import `lit/experimental-hydrate-support.js` in the browser environment. +These JSX runtime modules contain jsx functions enhanced to add the declarative shadow DOM output to registered custom elements when imported into server environemtns. They also automatically import `@lit-labs/ssr-client/lit-element-hydrate-support.js` in the browser environment. This method will not work for any pre-compiled JSX expressions or direct calls to `React.createElement()`, including those in the usage of the `@lit-labs/react` package's `createElement()`. Consider combining this with the [monkey patching](#monkey-patching-reactcreateelement-recommended) approach to handle such scenarios. diff --git a/packages/labs/ssr-react/package.json b/packages/labs/ssr-react/package.json index d25037d13f..56943d3e2b 100644 --- a/packages/labs/ssr-react/package.json +++ b/packages/labs/ssr-react/package.json @@ -48,6 +48,7 @@ ], "dependencies": { "@lit-labs/ssr": "^3.1.0", + "@lit-labs/ssr-client": "^1.0.1", "lit": "^2.7.0" }, "peerDependencies": { @@ -69,7 +70,8 @@ "command": "tsc --build --pretty", "clean": "if-file-deleted", "dependencies": [ - "../ssr:build:ts" + "../ssr:build:ts", + "../ssr-client:build:ts:types" ], "files": [ "src/**/*.ts{,x}", diff --git a/packages/labs/ssr-react/src/enable-lit-ssr.ts b/packages/labs/ssr-react/src/enable-lit-ssr.ts index cdb61c9977..b2a9783ab7 100644 --- a/packages/labs/ssr-react/src/enable-lit-ssr.ts +++ b/packages/labs/ssr-react/src/enable-lit-ssr.ts @@ -9,4 +9,4 @@ * any user code is loaded. Installs hydration support for `LitElement`. */ -import 'lit/experimental-hydrate-support.js'; +import '@lit-labs/ssr-client/lit-element-hydrate-support.js'; diff --git a/packages/labs/ssr-react/src/jsx-dev-runtime.ts b/packages/labs/ssr-react/src/jsx-dev-runtime.ts index 51a122dbdf..513a862a89 100644 --- a/packages/labs/ssr-react/src/jsx-dev-runtime.ts +++ b/packages/labs/ssr-react/src/jsx-dev-runtime.ts @@ -9,7 +9,7 @@ * development mode. For use in browsers. */ -import 'lit/experimental-hydrate-support.js'; +import '@lit-labs/ssr-client/lit-element-hydrate-support.js'; // eslint-disable-next-line import/extensions export {Fragment, jsxDEV} from 'react/jsx-dev-runtime'; diff --git a/packages/labs/ssr-react/src/jsx-runtime.ts b/packages/labs/ssr-react/src/jsx-runtime.ts index 8786f71dd4..e48be92ce2 100644 --- a/packages/labs/ssr-react/src/jsx-runtime.ts +++ b/packages/labs/ssr-react/src/jsx-runtime.ts @@ -9,6 +9,6 @@ * production mode. For use in browsers. */ -import 'lit/experimental-hydrate-support.js'; +import '@lit-labs/ssr-client/lit-element-hydrate-support.js'; // eslint-disable-next-line import/extensions export {Fragment, jsx, jsxs} from 'react/jsx-runtime'; diff --git a/packages/labs/ssr/README.md b/packages/labs/ssr/README.md index dbe41ced32..effc6142c9 100644 --- a/packages/labs/ssr/README.md +++ b/packages/labs/ssr/README.md @@ -74,14 +74,14 @@ context.body = Readable.from(ssrResult); ### Hydrating Lit templates -"Hydration" is the process of re-associating expressions in a template with the nodes they should update in the DOM. Hydration is performed by the `hydrate()` function from the `lit/experimental-hydrate.js` module. +"Hydration" is the process of re-associating expressions in a template with the nodes they should update in the DOM. Hydration is performed by the `hydrate()` function from the `@lit-labs/ssr-client` module. Prior to updating a server-rendered container using `render()`, you must first call `hydrate()` on that container using the same template and data that was used to render on the server: ```js import {myTemplate} from './my-template.js'; import {render} from 'lit'; -import {hydrate} from 'lit/experimental-hydrate.js'; +import {hydrate} from '@lit-labs/ssr-client'; // Initial hydration required before render: // (must be same data used to render on the server) const initialData = getInitialAppData(); @@ -95,7 +95,7 @@ const update = (data) => render(myTemplate(data), document.body); When `LitElement`s are server rendered, their shadow root contents are emitted inside a `