Author: Justin Fagnani
This proposal allows for multiple custom element definitions for a single tag name to exist within a page.
This is accomplished by allowing user code to create multiple custom element registries and associate them with shadow roots that function as scopes for element creation and custom element definitions. Potentially custom elements created within a scope use the registry for that scope to perform upgrades. New element construction APIs are added to ShadowRoot to allow element creation to be associated with a scope.
This proposal is focused on the MVP to provide encapsulation for element definitions, and it could be extended in the future if needed to provide more versatility.
It’s quite common for web applications to contain libraries from multiple sources, whether from different teams, vendors, package managers, etc. These libraries must currently contend for the global shared resource that is the CustomElementRegistry
. If more than one library (or more than one instance of a library) tries to define the same tag name, the application will fail.
Multiple versions of a library is a common source of this problem. This can happen for many reasons, but there are a few application/library types where this is common:
In addition to library duplication, there are other common use-cases for scopes:
Shadow roots provide an encapsulation mechanism for isolating a DOM tree’s, nodes, styles, and events from other scopes. The goal is to allow scopes to function without required coordination with other scopes. The globally shared custom element registry, however, requires coordination so that multiple scopes on a page agree on the registrations. Rather than invent a new scoping primitive to the DOM, it is natural to extend the shadow root scoping responsibilities to include custom element registrations.
This approach also allows for a nice API by extending the DocumentOrShadowRoot interface and ShadowRoot#attachShadow()
.
This proposal allows user code to create new instances of CustomElementRegistry
:
const registry = new CustomElementRegistry();
and associate them with a ShadowRoot:
export class MyElement extends HTMLElement {
constructor() {
this.attachShadow({mode: 'open', registry});
}
}
Such scoped registries can be populated with element definitions, completely under the control of the registry owner:
import {OtherElement} from './my-element.js';
registry.define('other-element', OtherElement);
Definitions in this registry do not apply to the main document, and vice-versa. The registry must contain definitions for all elements used.
Once a registry and scope are created, element creation associated with the scope will use that registry to look up custom element definitions:
this.shadowRoot.innerHTML = `<other-element></other-element>`;
These scoped registries will allow for different parts of a page to contain definitions for the same tag name.
CustomElementRegistry
A new CustomElementRegistry
is created with the CustomElementRegistry
constructor, and attached to a ShadowRoot with the registry
option to HTMLElement.prototype.attachShadow
:
import {OtherElement} from './my-element.js';
const registry = new CustomElementRegistry();
registry.define('other-element', OtherElement);
export class MyElement extends HTMLElement {
constructor() {
this.attachShadow({mode: 'open', registry});
}
}
Element creation APIs, like createElement()
and innerHTML
can be grouped into global API (those on Document or Window) and scoped APIs (those on HTMLElement and ShadowRoot). The scoped APIs have an associated Node that can be used to look up a CustomElementRegistry and thus a scoped definition.
In order to support scoped registries we add new scoped APIs, that were previously only available on Document
, to ShadowRoot
:
createElement()
createElementNS()
importNode()
These APIs work the same as their Document
equivalents, but use scoped registries instead of the global registry.
Because there is no longer a single global custom element registry, when creating elements, the steps to look up a custom element definition need to be updated to be able to find the correct registry.
That process needs to take a context node that is used to look up the definition. The registry is found by getting the context node’s root. If the root has a CustomElementRegistry
, use that registry to look up the definition, otherwise use the global objects CustomElementRegistry object.
The context node is the node that hosts the element creation API that was invoked, such as ShadowRoot.prototype.createElement()
, or HTMLElement.prototype.innerHTML
. For ShadowRoot.prototype.createElement()
, the context node and root are the same.
One consequence of looking up a registry from the root at element creation time is that different registries could be used over time for some APIs like HTMLElement.prototype.innerHTML, if the context node moves between shadow roots. This should be exceedingly rare though.
Another option for looking up registries is to store an element’s originating registry with the element. The Chrome DOM team was concerned about the small additional memory overhead on all elements. Looking up the root avoids this.
Constructors need special care with scoped registries. With a single global registry there is a strict 1-to-1 relationship between tag names and constructors. Scoped registries change this by allowing the same tag name to be associated with multiple constructors, which is solved by the altered look up a custom element definition process allowing the browser to find the correct constructor given a tag name.
As a result, it must limit constructors by default to only looking up registrations from the global registry. If the constructor is not defined in the global registry, it will throw.
This poses a limitation for authors trying to use the constructor to create new elements associated to scoped registries but not registered as global. More flexibility can be analyzed post MVP, for now, a user-land abstraction can help by keeping track of the constructor and its respective registry.
The CustomElementRegistry
constructor creates a new instance of CustomElementRegistry, independent of the instance available at window.customElements
.
const registry = new CustomElementRegistry();
ShadowRoot adds element creation APIs that were previously only available on Document:
createElement()
createElementNS()
importNode()
These are added to provide a root and possible registry to look up a custom element definition.
There are concern about what happens when an element with a custom registry moves to another document and the GC implications. We debated briefly about possible solutions:
adoptedCallback
. This is problematic because the registry is created before attaching the shadowRoot.Although these two proposals are in early stages, we need to solve the intersection semantics. There are two main issues:
mode
to indicate to the parser that a custom registry is going to be eventually associated to this shadow.attachShadow()
, and instead, something like ElementInternals
is much more flexible.To solve the problem of double-defining compatible versions of the same tag name, one could use a registration pattern with error handling:
try {
customElements.define(tagName, ctor, options);
} catch (e) {
// Hope the definitions are compatible and continue
}
However, there is no way to determine if the definitions are compatible. The resulting application execution may have subtle bugs.
Developers can technically already create new custom element scopes with iframes. Iframes also create new documents, style scopes, JavaScript realms and possibly security contexts. So they may indeed be better suited for some plug-in use-cases. But to be useful in the npm and complex application use-cases, at the limit every component would need to be in an iframe.