Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core/ssr] Add Node builds that don't explode #3156

Merged
merged 14 commits into from Aug 4, 2022
Merged

[core/ssr] Add Node builds that don't explode #3156

merged 14 commits into from Aug 4, 2022

Conversation

aomarks
Copy link
Member

@aomarks aomarks commented Jul 20, 2022

Background

Before this PR, importing Lit with Node causes errors such as window is not defined. The only way to load Lit was to first ensure that the dom-shim.js module provided by the @lit-labs/ssr package was loaded, which defines window plus APIs like HTMLElement and customElements.

This meant that using Lit in frameworks such as Next did not work out-of-the-box even when only client-side rendering is needed.

This change

After this PR, Lit can be imported from Node without errors, and custom elements can be defined as no-ops.

This doesn't remove the need for loading the DOM shim shim when SSR is actually needed, but it does give us compatibility at least for client-side rendering in frameworks like Next, without the user needing to do anything special.

This works by adding a Node-specific Rollup build and a node export condition to lit-html and @lit/reactive-element. Neither lit-element nor lit need a Node build, because only the underlying 2 libraries currently need Node-specific behavior.

This only defines customElements as new globals. It does not define window, HTMLElement, or any other APIs. This way, we won't affect the behavior of libraries that detect whether they are in Node vs the browser by checking for a window global.

For testing, I've added a node-imports.ts file to each package, and added a new node:test script which executes that module with Node. This confirms that Node doesn't crash on import.

Fixes #3079

Followup

In a follow up, I will propose moving the actual functional dom-shim implementations of HTMLElement and customElements from @lit-labs/ssr into reactive-element (in this PR they are just no-ops) so that ultimately the user never has to load the dom-shim manually.

However, in order to do this, we will need to update the Lit SSR ModuleLoader to understand export conditions -- because currently it does not support them, so it will not find the Node build. We depend on the resolve package to do module resolution, which unfortunately does not yet support export conditions, so this work will be a little bit non-trivial.

Another followup will be to add an isSsr const or function. Rather than doing runtime detection, this can simply be true in the node build and false in the browser build.

Size

This actually slightly drops the size of the main lit-html.js and reactive-element.js production build files:

File Old size (raw/gzip/brotli) New size (raw/gzip/brotli)
lit-html.js 8.68 / 3.45 / 3.13 8.02 / 3.38 / 3.07
reactive-element.js 5.97 / 2.17 / 1.93 5.95 / 2.17 / 1.92

@changeset-bot
Copy link

changeset-bot bot commented Jul 20, 2022

🦋 Changeset detected

Latest commit: 8367869

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
lit Minor
lit-html Minor
@lit/reactive-element Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Jul 20, 2022

📊 Tachometer Benchmark Results

Summary

nop-update

  • lit-html-kitchen-sink: unsure 🔍 -4% - +8% (-0.99ms - +2.10ms)
    this-change vs tip-of-tree

render

  • lit-element-list: 72.30ms - 75.18ms
  • lit-html-kitchen-sink: unsure 🔍 -1% - +1% (-0.15ms - +0.23ms)
    this-change vs tip-of-tree
  • lit-html-repeat: faster ✔ 0% - 4% (0.00ms - 0.41ms)
    this-change vs tip-of-tree
  • lit-html-template-heavy: unsure 🔍 -0% - +2% (-0.18ms - +0.81ms)
    this-change vs tip-of-tree
  • reactive-element-list: unsure 🔍 -2% - +0% (-1.07ms - +0.20ms)
    this-change vs tip-of-tree

update

  • lit-element-list: 677.63ms - 687.99ms
  • lit-html-kitchen-sink: unsure 🔍 -3% - +3% (-2.27ms - +2.35ms)
    this-change vs tip-of-tree
  • lit-html-repeat: unsure 🔍 -1% - +1% (-2.54ms - +2.18ms)
    this-change vs tip-of-tree
  • lit-html-template-heavy: unsure 🔍 -0% - +1% (-0.53ms - +1.60ms)
    this-change vs tip-of-tree
  • reactive-element-list: unsure 🔍 -1% - +0% (-4.05ms - +2.73ms)
    this-change vs tip-of-tree

update-reflect

  • lit-element-list: 692.49ms - 698.71ms
  • reactive-element-list: unsure 🔍 -1% - +1% (-8.12ms - +7.44ms)
    this-change vs tip-of-tree

Results

lit-element-list

render

VersionAvg timevs
72.30ms - 75.18ms-

update

VersionAvg timevs
677.63ms - 687.99ms-

update-reflect

VersionAvg timevs
692.49ms - 698.71ms-
lit-html-kitchen-sink

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
28.17ms - 28.45ms-unsure 🔍
-1% - +1%
-0.15ms - +0.23ms
unsure 🔍
-1% - +1%
-0.35ms - +0.15ms
tip-of-tree
tip-of-tree
28.13ms - 28.39msunsure 🔍
-1% - +1%
-0.23ms - +0.15ms
-unsure 🔍
-1% - +0%
-0.39ms - +0.10ms
previous-release
previous-release
28.20ms - 28.62msunsure 🔍
-1% - +1%
-0.15ms - +0.35ms
unsure 🔍
-0% - +1%
-0.10ms - +0.39ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
76.70ms - 79.96ms-unsure 🔍
-3% - +3%
-2.27ms - +2.35ms
unsure 🔍
-3% - +3%
-2.75ms - +1.98ms
tip-of-tree
tip-of-tree
76.65ms - 79.93msunsure 🔍
-3% - +3%
-2.35ms - +2.27ms
-unsure 🔍
-4% - +2%
-2.79ms - +1.95ms
previous-release
previous-release
77.00ms - 80.42msunsure 🔍
-3% - +4%
-1.98ms - +2.75ms
unsure 🔍
-2% - +4%
-1.95ms - +2.79ms
-

nop-update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
25.23ms - 28.29ms-unsure 🔍
-4% - +8%
-0.99ms - +2.10ms
unsure 🔍
-3% - +9%
-0.77ms - +2.31ms
tip-of-tree
tip-of-tree
25.97ms - 26.44msunsure 🔍
-8% - +4%
-2.10ms - +0.99ms
-unsure 🔍
-0% - +2%
-0.06ms - +0.49ms
previous-release
previous-release
25.85ms - 26.13msunsure 🔍
-8% - +3%
-2.31ms - +0.77ms
unsure 🔍
-2% - +0%
-0.49ms - +0.06ms
-
lit-html-repeat

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
10.15ms - 10.37ms-faster ✔
0% - 4%
0.00ms - 0.41ms
unsure 🔍
-5% - +1%
-0.56ms - +0.07ms
tip-of-tree
tip-of-tree
10.30ms - 10.63msslower ❌
0% - 4%
0.00ms - 0.41ms
-unsure 🔍
-4% - +3%
-0.38ms - +0.29ms
previous-release
previous-release
10.21ms - 10.80msunsure 🔍
-1% - +5%
-0.07ms - +0.56ms
unsure 🔍
-3% - +4%
-0.29ms - +0.38ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
267.30ms - 270.55ms-unsure 🔍
-1% - +1%
-2.54ms - +2.18ms
unsure 🔍
-2% - +0%
-5.13ms - +0.96ms
tip-of-tree
tip-of-tree
267.38ms - 270.82msunsure 🔍
-1% - +1%
-2.18ms - +2.54ms
-unsure 🔍
-2% - +0%
-5.00ms - +1.19ms
previous-release
previous-release
268.43ms - 273.58msunsure 🔍
-0% - +2%
-0.96ms - +5.13ms
unsure 🔍
-0% - +2%
-1.19ms - +5.00ms
-
lit-html-template-heavy

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
51.75ms - 52.52ms-unsure 🔍
-0% - +2%
-0.18ms - +0.81ms
unsure 🔍
-0% - +2%
-0.13ms - +0.83ms
tip-of-tree
tip-of-tree
51.52ms - 52.13msunsure 🔍
-2% - +0%
-0.81ms - +0.18ms
-unsure 🔍
-1% - +1%
-0.38ms - +0.46ms
previous-release
previous-release
51.50ms - 52.07msunsure 🔍
-2% - +0%
-0.83ms - +0.13ms
unsure 🔍
-1% - +1%
-0.46ms - +0.38ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
108.63ms - 110.36ms-unsure 🔍
-0% - +1%
-0.53ms - +1.60ms
unsure 🔍
-1% - +1%
-0.76ms - +1.47ms
tip-of-tree
tip-of-tree
108.33ms - 109.58msunsure 🔍
-1% - +0%
-1.60ms - +0.53ms
-unsure 🔍
-1% - +1%
-1.12ms - +0.75ms
previous-release
previous-release
108.44ms - 109.84msunsure 🔍
-1% - +1%
-1.47ms - +0.76ms
unsure 🔍
-1% - +1%
-0.75ms - +1.12ms
-
reactive-element-list

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
54.13ms - 54.98ms-unsure 🔍
-2% - +0%
-1.07ms - +0.20ms
unsure 🔍
-2% - +1%
-1.05ms - +0.34ms
tip-of-tree
tip-of-tree
54.52ms - 55.47msunsure 🔍
-0% - +2%
-0.20ms - +1.07ms
-unsure 🔍
-1% - +1%
-0.64ms - +0.81ms
previous-release
previous-release
54.36ms - 55.46msunsure 🔍
-1% - +2%
-0.34ms - +1.05ms
unsure 🔍
-1% - +1%
-0.81ms - +0.64ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
690.26ms - 695.20ms-unsure 🔍
-1% - +0%
-4.05ms - +2.73ms
unsure 🔍
-1% - +0%
-3.56ms - +3.30ms
tip-of-tree
tip-of-tree
691.06ms - 695.71msunsure 🔍
-0% - +1%
-2.73ms - +4.05ms
-unsure 🔍
-0% - +1%
-2.80ms - +3.85ms
previous-release
previous-release
690.48ms - 695.24msunsure 🔍
-0% - +1%
-3.30ms - +3.56ms
unsure 🔍
-1% - +0%
-3.85ms - +2.80ms
-

update-reflect

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
800.80ms - 812.12ms-unsure 🔍
-1% - +1%
-8.12ms - +7.44ms
unsure 🔍
-1% - +1%
-7.10ms - +8.42ms
tip-of-tree
tip-of-tree
801.47ms - 812.14msunsure 🔍
-1% - +1%
-7.44ms - +8.12ms
-unsure 🔍
-1% - +1%
-6.53ms - +8.52ms
previous-release
previous-release
800.49ms - 811.12msunsure 🔍
-1% - +1%
-8.42ms - +7.10ms
unsure 🔍
-1% - +1%
-8.52ms - +6.53ms
-

tachometer-reporter-action v2 for Benchmarks

Copy link
Member

@augustjk augustjk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exciting!

packages/lit-html/src/lit-html.ts Show resolved Hide resolved
rollup-common.js Show resolved Hide resolved
rollup-common.js Outdated
@@ -255,6 +255,7 @@ export function litProdConfig({
packageName,
outputDir = './',
copyHtmlTests = true,
nodeBuild = false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: naming, maybe includeNodeBuild? (so it doesn't sound like you're configuring the build to be for node specifically)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


if (NODE_MODE) {
global.HTMLElement ??= class HTMLElement {} as unknown as typeof HTMLElement;
global.customElements ??= {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All things equal, if a user didn't explicitly load the dom-shim, it'd be nice to keep as much as possible off the global. I think we could pull HTMLElement into a local variable (and shim it there) rather than put it on the global, right?

customElements is a little harder; we could do the same in the decorator, and that would just leave JS users to needing to do customElements?.define(...) in their own elements. But maybe it's worth keeping customElements on the global to help smooth that over?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I think if this approach is good it's exactly because it 1) puts all the cost on the node import, so 2) can go to extra lengths to isolate any shims to just this module.

I think we should be able to do something like:

const HTMLElementShim = class HTMLElement { ... }
export class ReactiveElement extends (NODE ? HTMLElementShim : HTMLElement) {

Regarding window.customElements, shouldn't that be shimmed, if at all, in the decorator definition module? And there we just need the decorator to not access it, yeah?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's sync on this offline. It seems like Al is moving towards not having a DOM shim separate from 'lit is loaded in Node'? If that' the case, we do want to populate the global in the Node import condition, yeah?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per meeting this morning, HTMLElement is no longer globally shimmed, it is instead just declared inline. The reason we don't need HTMLElement globally is that we're only concerned with users who are extending ReactiveElement or LitElement.

I have kept the global customElements definitions so that it will be possible to import components that aren't defined using the decorator.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just pushed another change that shims HTMLElement in a slightly more sneaky way. It now temporarily defines globalThis.HTMLElement, and then removes it after ReactiveElement is defined, instead of changing the extends clause at all.

The reason for this is that I found that the following code ... :

export abstract class ReactiveElement
  extends (NODE_MODE && global.HTMLElement === undefined
    ? (class HTMLElement {} as unknown as typeof HTMLElement)
    : HTMLElement)

... resulted in in this d.ts output:

declare const ReactiveElement_base: {
    new (): HTMLElement;
    prototype: HTMLElement;
};
/**
 * Base element class which manages element properties and attributes. When
 * properties change, the `update` method is asynchronously called. This method
 * should be supplied by subclassers to render updates as desired.
 * @noInheritDoc
 */
export declare abstract class ReactiveElement extends ReactiveElement_base implements ReactiveControllerHost {

Which, for some reason, causes ts-transformers to fail with this error:

        --··"../reactive-element/reactive-element.d.ts(181,55):·error·TS2749:·'ReactiveElement_base'·refers·to·a·value,·but·is·being·used·as·a·type·here.·Did·you·mean·'typeof·ReactiveElement_base'?\n",

I also have a suspicion that the ternary extends could be upsetting to Closure compiler.

Open to other suggestions, but this approach of defining and then undefining it should be the least confusing to type systems.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of any better way. I tried playing around with it a bit but TS also did not like an exported class extending something that's module scoped.

import {LitElement, html} from 'lit-element';
import {customElement} from 'lit-element/decorators.js';

@customElement('my-element')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense to include a version that doesn't use the decorator as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

packages/lit-html/src/lit-html.ts Show resolved Hide resolved
/**
* A tiny module scoped polyfill for HTMLSlotElement.assignedElements.
*/
const slotAssignedElements =
window.HTMLSlotElement?.prototype.assignedElements != null
global.HTMLSlotElement?.prototype.assignedElements != null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is HTMLSlotElement going to be defined in Node? Is that in the dom-shim? And what about all the other element classes? Users probably won't use ?. for these as is done here, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, and the broader DOM shim doesn't define HTMLSlotElement either. Somebody could be doing e.g. instanceof HTMLSlotElement somewhere, but I think we don't need to worry about it for now.

import {customElement} from '@lit/reactive-element/decorators.js';
import {ReactiveElement} from '@lit/reactive-element';

@customElement('my-element')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a version that doesn't use the decorator?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


if (NODE_MODE) {
global.HTMLElement ??= class HTMLElement {} as unknown as typeof HTMLElement;
global.customElements ??= {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's sync on this offline. It seems like Al is moving towards not having a DOM shim separate from 'lit is loaded in Node'? If that' the case, we do want to populate the global in the Node import condition, yeah?

@aomarks
Copy link
Member Author

aomarks commented Jul 27, 2022

Just added a fix to the update-version-variables.js script to account for the fact that the code is sometimes now on global instead of globalThis.

Copy link
Member

@augustjk augustjk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by testing pre-release that this does indeed create node builds that don't explode.

);
let replaced = false;
const newSource = fileSource.replace(versionVarRegex, () => {
const newSource = fileSource.replace(versionVarRegex, (_, pre, post) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh very nice utilization of capture groups.

Copy link
Member

@kevinpschaaf kevinpschaaf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Since we have to commit to this approach long-term, I'm mildly interested in how much code it would add if we just did the shimming in the normal codepath (and not have a separate node export condition and all the config file overhead that requires).

But since as you note the node export condition option will also give us a nice place to put the isSSR flag, seems like that having a NODE_MODE will end up being useful for a number of reasons, so I think I'm good with this direction.

@aomarks
Copy link
Member Author

aomarks commented Aug 3, 2022

Looks good.

Since we have to commit to this approach long-term, I'm mildly interested in how much code it would add if we just did the shimming in the normal codepath (and not have a separate node export condition and all the config file overhead that requires).

Below is a comparison of the sizes of the two main affected files. "Prod" is the minified browser build (NODE_MODE=false with unreachable code removed by terser). "Combined" is if we were to just have a single build with a runtime environment check (NODE_MODE = typeof window === 'undefined'):

Build File name Size Gzipped Brotli
Prod lit-html.js 8.02 kB 3.38 kB 3.07 kB
Combined lit-html.js 8.09 kB 3.41 kB 3.1 kB
Prod reactive-element.js 5.95 kB 2.17 kB 1.92 kB
Combined reactive-element.js 6.16 kB 2.26 kB 2 kB

So, a little more substantial on the reactive-element.js size.

Also I believe this difference will increase even further, because I plan to extend the Node customElement.define implementation in reactive-element.js to keep track of calls instead of being a no-op, so that they can be later replayed if the full fidelity shim is loaded afterwards.

@kevinpschaaf
Copy link
Member

Cool thanks. The 90-100b on RE is definitely worth it, plus what you said about possibly expanding it a bit, so I think I'm sold.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[labs/ssr] Make Lit components less explody in Node
5 participants