Motivation
There are a couple of reasons why developers may want to run components in isolated workers:
- Applications may want to run untrusted code with UI, such as a third-party plugins. iframes are sometimes appropriate for this, but not always. A relevant example for Lit might be Photoshop for the web, which happens to be written in Lit, and would be likely to want a secure plugin model.
- Applications may be able to move some computationally expensive work off main thread to a worker.
I propose we focus on the first motivation, since experiments on moving UI work into workers generally haven't paid off due to the cost of communication overheard of workers. Especially without an expensive vdom diff/reconciliation phase, lit-html updates aren't often the bottleneck. For performance improvements, it's usually better to move expensive computation to the worker, and keep rendering on the main thread.
How
To run UI code in a worker we need a few things:
- Load a worker-rendering protocol module, plus template/component-containing modules, in a worker
- Register templates and components by identifier
- Send a request for a render to a worker with a template or component ID and data
- Instantiate components in the worker as necessary
- Send a result to be rendered to the main thread
- Render the result with standard Lit behavior, including stable, minimal DOM updates
- Handling UI events on the main thread and sending them to the worker
This might be nicely done by abstracting over workers in a way to install the infrastructure for the worker-rendering protocol, and creating a proxy element in the main thread to apply render results and maintain communication with the worker instantiated component.
Image a main thread API like:
import {createWorkerElement} from '@lit-labs/workers';
// Create a local element class that's a proxy to a worker element named 'my-worker-element'
// With this API we could have one worker per element instance, which might be expensive, but
// would maximally isolate sate for use cases like plugins. This component could still have child
// components in the same worker.
// Worker-to-instance affiliation could be configurable.
const workerElementClass = createWorkerElement({
tagName: 'my-worker-component',
url: new URL('./my-worker-component.js', import.meta.url),
});
// Register the local proxy
customElements.define('my-local-element', workerElementClass);
// Create a local instance which will automatically create a worker and start rendering
document.createElement('my-local-element');
In the worker, we would like the API to appear to be as plain as main thread Lit components. Possibly we patch the LitElement prototype to send the result of this.render()
to the main thread.
Rendering lit-html templates across worker boundaries
lit-html TemplateResults are the objects that represent the evaluation of a template expression.
For a template like:
html`<h1>Hello, ${name}!</h1>`
A TemplateResult would look like:
{
_$litType$: 1, // 1 for html, 2 for svg
strings: ['<h1>Hello, ', '!</h1>'], // This is actually a template strings array
values: ['Workers']
}
This object can be sent via postMessage()
(as long as the binding values are), and a TemplateResult can be reconstructed on the main thread side.
The trickiest part is to keep the tagged-template-literal semantics that lit-html relies on so that the strings array is referentially identical across multiple results. We can do this, and optimize postMessage()
payload size, with a cache in each thread.
The first time we send a TemplateResult for a particular template expression, we'll send the result, but add a unique and durable ID:
{
_$litType$: 1,
_$stringsId$: 1,
strings: ['<h1>Hello, ', '!</h1>'],
values: ['Workers']
}
The main thread will store the strings
array, keyed by the _$stringsId$
.
Subsequent messages with TemplateResults from this template expression will only contain the ID:
{
_$litType$: 1,
_$stringsId$: 1,
values: ['Workers']
}
The main thread will then add back in the cached strings array resulting in a TemplateResult object that can be correctly rendered by lit-html.
We will have to do a similar thing for styles.
Compatible data types
postMessage()
supports send objects compatible with the structured clone algorithm, which includes primitives, objects (with cyclical references supported), Arrays, Maps, Sets, Dates, Regexps, and more.
A few notable omissions:
- Symbols
- Functions
- DOM objects including Node and Event
- Prototype chains are not walked - only own properties are sent
- Private fields will not be sent (nor their WeakMap equivalent)
This means that we will have to traverse messages and transform them to proxy certain unsupported data types, especially functions and events for event handling.
The local proxy
The local proxy element will have to send messages to the worker with new data to render. This should be doable in a performUpdate()
override. It will send a payload with the current and old values of declared reactive properties and attributes. Attributes have to be sent separately from properties because attribute converter functions have to run in the worker. We can't rely on them being reflected to properties in the main thread.
The local proxy will have to add event handlers for worker components. This can be done by proxying event handlers in templates, and patching addEventListener()
in the worker to cause it to register an event handler in the proxy.
Patches to LitElement in workers
Plain LitElement's update()
implementation calls this.render()
and then passes the result to lit-html's render()
function. We will have to patch the LitElement in the worker to instead pass the result to the main thread as a message.
DOM restrictions
Since there is no DOM in workers, components will be restricted to APIs similarly to SSR-compatible Lit elements.
We could also more completely emulate the DOM in workers, such as with projects like Partytown or WorkerDOM.
Nested components
The most difficult design area is likely how to deal with nested elements.
There will be two kinds of nested elements:
- Components that should also run in the worker
- Components that can be assumed to be present in the main thread, provided by the host application.
Components that should also run in the worker
The first case is more complicated.
Typically we use the DOM itself to propagate state down the tree. When a parent renders, it may set properties on a child causing it to render. If we did that for nested worker components we would cause a cascade of messages to a worker for the parent, back to the main thread where we set properties on the child, causing another message to the worker for the child.
We would instead like to run the child in the worker, send properties to it in the worker while rendering the parent, and render both parent and child in one message/reply turn. This is actually very similar to how SSR works, so we should be able to reuse some of the SSR infrastructure. We can parse templates, find the child components, and instantiate and render them.
Main thread components
Main thread child components are similar to built-in HTML elements with the one exception that they may be imported by the parent element. This could be a problem if the child component is not worker compatible. For parent components specifically written for workers, they can just elide the imports for their built-in children.