Justin Fagnani, Google software engineer, and creator of lit-html, joined us for a Vaadin webinar in which he presented the new and exciting releases of LitElement 3.0 and lit-html 2.0. You can find the full recorded webinar on Youtube or continue reading for a written summary.
Thank you Justin Fagnani for the webinar!
Vaadin and LitElement
Vaadin is a set of two interoperable web frameworks for building UIs: Vaadin Flow enables you to build a UI 100% in Java and Vaadin Fusion is for developing reactive client-side web apps on a Java backend.
We at Vaadin are excited about the new Lit releases, especially since both Vaadin Flow and Fusion support LitElement-based web components and Vaadin Fusion uses LitElement as its templating language.
The Vaadin frameworks and web component directory are open-source software.
A little bit of history
The first lit-html prototype was released when Polymer 3 came out in about mid-2017; it was presented at the Polymer Summit that year. They updated the prototype based on feedback on the initial release of lit-html 1.0 and LitElement 2.0 in 2019. The version numbering does not match since the npm-package name ‘LitElement 1.0’ was already taken at the time of first release.
Now, after 2 years of stability, they’re releasing new versions: lit-html 2.0 and LitElement 3.0. Let’s get into the whys and hows of the release.
Polymer 2.0 and 3.0
Here we have a HTML template and inside it are some expressions [1]
. These expressions are Polymer expressions, even though they might look like JavaScript.
Polymer 2 HTML imports:
<dom-module id=”my element”>
<template>
<style>
.response { margin-top: 10px; }
</style>
<paper-checkbox checked=””[1]>I like web components.</paper-checkbox>
<div hidden$=”[[!liked]]” class=”response”>Web components like you, too.</div>
</template>
<!--...-->
</dom-module>
The biggest change in Polymer 3 is that the file format changed from .html
to .js
. This was around the time major browsers began supporting JavaScript modules natively. This was achieved with a html
tag [1]
in a tag. However, the expressions [2]
and [3]
were still Polymer expressions, which meant JavaScript tooling, such as code completion, doesn’t work on them.
Polymer 3: JavaScript Modules:
class MyElements extends PolymerElement {
static template = html`[1]
<style>
.response { margin-top: 10px; }
</style>
<paper-checkbox checked=””[2]>I like web components.</paper-checkbox>
<div hidden$=”[[!liked]]”[3] class=”response”>Web components like you, too.</div>
;
}
The solution was for Lit to change the template system to enable the use of JavaScript expressions within the ${}
brackets.
LitElement w/ lit-html: JavaScript Templates:
class MyElements extends LitElement {
static styles = css`
.response { margin-top: 10px; }
`;
render();
return html
<paper-checkbox checked=${this.liked}>I like web components.</paper-checkbox>
<div hidden$=${!this.liked} class=”response”>Web components like you, too.</div>
;
}
}
This brought several tooling and compatibility benefits, and changed data binding from two-way to one-way binding.
LitElement vs. Polymer
- Smaller: no need for an expression parser and validator.
- Faster: the expression only has to use the JavaScript parser.
- More powerful: you can use it for loops, to switch templates, and so forth.
- Better DX: they felt the role of a component is to merge data and DOM, which ends up being a lot easier on the data-side with access to JavaScript imports and utility functions.
What's next?
Lit have opened some issues in the repositories and have asked for suggestions and feedback on new features.
Ideas for next major versions
lit-html 2.0
- Server-side rendering (SSR) support
- Removal of browser bug workarounds
- Dev build for better DX
- Prod build with max minification
- API cleanup
- More powerful directives
- Static bindings
- Element bindings / spread
LitElement 3.0
- SSR support
- Separate polyfill support
- Dev build for better DX
- Prod build with max minification
- API cleanup
- Code sharing pattern
These ideas were then categorized into:
- New features
- Better DX
- Code reuse
- Smaller & faster
- SSR
For the team, SSR support was the top priority, followed by new features and improved DX. However, enabling these changes required these breaking changes:
- Remove template processor API
- Remove browser bug workarounds
- Change directive authoring API
- Add package exports
- Move polyfill support to separate file
- Don’t export decorators from main module
Minimal breaking changes
They kept breaking-changes to a minimum to make the new releases a drop-in replacement for most users. The minimal breaking changes that you need to be aware of are small API changes, publishing ES2020, and a different way to handle directives. The breaking changes are not required, unless you write directives or request updates. The example below shows how the code stays identical between LitElement 2.4 and the new LitElement 3.0.
LitElement 2.4:
class MyElements extends LitElement {
static styles = css`
.response { margin-top: 10px; }
`;
render() {
return html`
<paper-checkbox checked=${this.liked}>I like web components.</paper-checkbox>
<div hidden$=${!this.liked} class=”response”>Web components like you, too.</div>
;
}
}
LitElement 3.0:
class MyElements extends LitElement {
static styles = css`
.response { margin-top: 10px; }
`;
render() {
return html`
<paper-checkbox checked=${this.liked}>I like web components.</paper-checkbox>
<div hidden$=${!this.liked} class=”response”>Web components like you, too.</div>
;
}
}
New features
Element expressions
There are two types of expressions in lit-html 1.0:
- Attribute expressions (require an attribute name)
- Child node position expressions
In 2.0, they were able to rework the pre-processing step to support expressions on an element that does not have an attribute name. At the moment, these can only take directives, so you can’t pass a value that will be rendered to the element, but they are great for attaching behaviour.
Here’s an example with a Vaadin text field:
Element expressions:
<vaadin-text-field
label=”Full name”
…[1]=”${field(model.fullName)}”
></vaadin-text-field>
In the current version, the attribute requires a field name [1]
such as “...
” to apply the directive. It’ll be enough to just have an expression on the element itself in later versions.
<vaadin-text-field
label=”Full name”
${field(model.fullName)}”
></vaadin-text-field>
Furthermore, you can have several behaviours attached to a single element this way.
Static expressions
HTML typically separates the static template from the dynamic values. It turns out that for some dynamic and advanced use-cases, it’s better to be able to dynamically create a template. For example, you cannot change a tag name in the DOM without replacing the element.
To be able to do this, the team created an overlay module, static.js
, on top of lit-html.
Static expressions:
import {litElement} from 'lit';
import {customElement} from 'lit/decorators.js'
import {html, unsafeStatic} from 'lit/static-html.js';[1]
@customElement('my-element')
export class MyElement extends LitElement {
render() {
const tagName = unsafeStatic('div');
return html`<${tagName}[2]>Hello</${tagName}>`;
This works by importing html
from a new wrapper called unsafeStatic
[1]
and using it to place elements in new places in your template [2]
where you couldn’t use bindings before.
However, it’s worth noting that this can create an XSS (cross-site scripting) vulnerability for sensitive data that lit is otherwise immune to. These static expressions are inherently unsafe, have values that are interpolated before template parsing and can change static values.
Class-based directives
The new class-based directives are naturally SSR compatible and automatically store state as class fields.
lit-html 1.0 directives:
const HelloDirective = directive(()[1] => (part)[2] => {
part.setValue('Hello');
});
In lit-html 1.0, you can see a double functioning cascade [1]
after the perimeter that does not take any parameters on its own. Instead it creates a part
[2]
that you can assign functions to comparatively.
lit-html 2.0 directives:
class HelloDirective[2] extends Directive {
render()[1] {
return 'Hello'
}
}
const HelloDirective = directive(HelloDirective);[3]
In lit-html 2.0, they turned this into a render method [1]
on a directive class [2]
that is then passed through the function factory [3]
that gives instructions for creating an instance of the class.
This is particularly useful when you have statefulness. The example below shows a counter directive that renders the content you pass to it [3]
plus the number of times it has been rendered [4]
. To store the state in lit-html 1.0, you had to create a WeakMap
[1]
to key the part
[2]
.
lit-html 1.0 directives - stateful updates:
const counts = new WeakMap[1]<Part, number>();
const countDirective = directive((v: unknown) => (part: Part) => {
let count = counts.get(part)[2];
if (count === undefined) {
count = 0;
}
counts.set(part, count++);
part.SetValue([v,[3] count[4]]);
});
In lit-html 2.0, it’s now a class, and lit creates the instance of the class where you can store the state for you [1]
. This also supports SSR, where you can use html expressions in your render method [2]
.
lit-html 2.0 directives - stateful updates:
class CountDirective extends Directive {
count = 0;[1]
[2]render(v: unknown) {
return html`${v}:${this.count}`;
}
update(part: ChildPart, [v]: DirectiveParameters<this>) {
this.count++;
return this.render(v);
}
}
const countDirective = count(CountDirective);
Async directives
Lit had long-standing requests for directives that could be notified when they were connected and disconnected from the DOM. Async directives are a new subclass of directives with a disconnect and reconnect callback. They can also update template values outside of the normal render()
lifecycle.
These are great anywhere, when you need to clean up subscriptions to free up the memory.
Reactive controllers
The next feature is the biggest addition to new versions of the library: Reactive Controllers. These are new code reuse patterns: objects that allow you to build similar functionality to React Hooks or Vue.js composition API.
Reactive controllers maintain their own state, but don’t have a render callback. Instead, they hook into the host components lifecycle. LitElement now has an API called the addController that will call into any controller added with it for things like update
, updated
and connected
etc.
The controllers can also trigger a host update by request.
Using them is rather simple. In the example below, the controllers are defined as the interface with four lifecycle points besides the constructor. These lifecycle points are called from LitElement when the host hits these lifecycle points.
Reactive Controller API:
interface ReactiveController {
hostConnected?(): void;
hostDisconnected?(): void;
hostUpdate?(): void;
hostUpdated?(): void;
}
They have also defined a Reactive Controller Host API that only requires the host to add and remove a controller. It doesn't extend LitElement or html and is therefore not LitElement-specific.
interface ReactiveControllerHost {
addController(controller: ReactiveController): void;
removeController(controller: ReactiveController): void;
requestUpdate(): void;
readonly updateComplete: Promise<boolean>;
}
See the recording for a live demonstration of this feature (timestamp at 27:59).
Controller directives
Directives can now also be controllers. These controller directives can perform logic at any lifecycle event, i.e. they can perform work before and after render instead of only during render. This enables you to measure an element before the render–before you start to change it.
This applies well when doing animation transitions. The new flip()
[1]
controller directive measures the state of the element before and after to create a FLIP animation. FLIP stands for:
- First: Measure in hostUpdate()
- Last: Measure in hostUpdated()
- Invert: Apply inverse styles
- Play: Apply keyframes
Controller directives: flip():
render () {
return html`
<button @click=${this.shuffle}>Shuffle</button>
<h2>Items>/h2>
${repeat(this.items, (i) => i, (i) => html`
<div class=”item” ${flip()[1]}>${i}</div>
`)}
`;
}
You can see this code in action in the recording (timestamp at 34:14).
Code size and performance
Lit is known for being small, and even with the new features, maintaining (or decreasing) its size has stayed a priority. They managed to decrease the size significantly, while increasing performance and adding new features!
lit-html 2.0 size compared to 1.3.0:
-34% minified: 6.3KB
-18% gzip: 2.8KB
-18% brotli: 2.5KB
lit-element 3.0 size compared to 2.4.0:
-40% minified: 13KB
-27% gzip: 7.1KB
-27% brotli: 6.4KB
Overall benchmarks
Performance also got a boost: the new versions have a 5%-20% faster initial render and 7%-15% faster update.
SSR
Even though SSR was the driver for these improvements, they are not releasing a production-ready SSR framework just yet. However, LitElement and lit-html are SSR ready: hydration has been implemented and the hydration modules ship with the new releases. In addition, all built-in directives are server compatible.
A Node renderer will also be available in an experimental state.
SSR design goals
The SSR goals are ambitious, and they are aiming for cutting-edge SSR performance:
- Streaming: Low TTFB (time-to-first-byte).
- Incremental: Can hydrate subtrees independently (without unnecessary DOM mutations).
- Interoperable: Core functionality not coupled to LitElement.
- Full fidelity: Uses declarative Shadow DOM.
Summary
The new LitElement and lit-html versions are smaller, better, faster and server ready. The brand new ReactiveController feature allows developers to create shareable logic that hooks into the component lifecycle.
The team is currently building a new website and you can expect its release soon.
Meanwhile, you can follow the Polymer Project on twitter @polymer for updates or watch the Q&A session and live demonstrations from the webinar recording.