Skip to content

Instantly share code, notes, and snippets.

@justinfagnani
Last active June 26, 2023 02:08
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save justinfagnani/d67d5a5175ec220e1f3768ec67a056bc to your computer and use it in GitHub Desktop.
Save justinfagnani/d67d5a5175ec220e1f3768ec67a056bc to your computer and use it in GitHub Desktop.
Scoped Custom Element Registries

Outdated - current proposal is at https://github.com/justinfagnani/scoped-custom-elements

Scoped Custom Element Definitions

Overview

Scoped Custom Element definitions is an oft-requested feature of Web Components. The global registry is a possible source of name collisions that may arise from coincidence, or from an app trying to define multiple versions of the same element, or from more advanced scenarios like registering mocks during tests, or a component explicitly replacing an element definition for its scope.

Since the key DOM creation APIs are global, scoping definitions is tricky because we'd need a machanis to determind which scope to use. But if we offer scoped versions of these APIs the problem is tractable. This requires that DOM creation code is upgraded to use the new scoped APIs, something that hopefully could be done in template libraries and frameworks.

This proposal adds the ability to construct CustomElementRegistrys and chain them in order to inherit custom element definitions. It uses ShadowRoot as a scope for definitions. ShadowRoot can be associated with a CustomElementRegistry when created and gains element creation methods, like createElement. When new elements are created within a ShadowRoot, that ShadowRoot's registry is used to Custom Element upgrades.

API Changes

CustomElementRegistry

  • CustomElementRegistry(parent?: CustomElementRegistry)

    CustomElementRegistry is constructable, and able to inherit from a parent registry.

    New definitions added to a registry are not visible to the parent, and mask any registrations with the same name defined in the parent so that definitions can be overridden.

  • CustomElementRegistry.prototype.get(name: string)

    get() now returns the closest constructor defined for a tag name in a chain of registries.

  • CustomElementRegistry.prototype.getRegistry(name: string)

    Returns the closest registry in which a tag name is defined.

ShadowRoot

ShadowRoots are already the scoping boundary for DOM and CSS, so it's natural to be the scope for custom elements. ShadowRoot needs a CustomElementRegistry and the DOM creation APIs that current exist on document.

  • customElements: CustomElementRegistry

    The CustomElementRegistry the ShadowRoot uses, set on attachShadowRoot().

  • createElement(), createElementNS() These methods create new elements using the CustomElementRegistry of the ShadowRoot.

  • importNode() Imports a node into the document that owns the ShadowRoot, using the CustomElementRegistry of the ShadowRoot.

    This enables cloning a template into multiple scopes to use different custom element definitions.

Element

New properties:

  • Element.prototype.scope: Document | ShadowRoot Elements have DOM creation APIs, like innerHTML, so they need a reference to their scope. Elements expose this with a scope property. One difference between this and getRootNode() is that the scope for an element can never change.

  • Element.prototype.attachShadow(init: ShadowRootInit)

    ShadowRootInit adds a new property, customElements, in its options argument which is a CustomElementRegistry.

With a scope, DOM creation APIs like innerHTML and insertAdjacentHTML will use the element's scope's registry to construct new custom elements. Appending or inserting an existing element doesn't use the scope, nor does it change the scope of the appended element. Scopes are completely defined when an element is created.

Example

// x-foo.js is an existing custom element module that registers a class
// as 'x-foo' in the global registry.
import {XFoo} from './x-foo.js';

// Create a new registry that inherits from the global registry
const myRegistry = new CustomElementRegistry(window.customElements);

// Define a trivial subclass of XFoo so that we can register it ourselves
class MyFoo extends XFoo {}

// Register it as `my-foo` locally.
myRegistry.define('my-foo', MyFoo);

class MyElement extends HTMLElement {
  constructor() {
    super();
    // Use the local registry when creating the ShadowRoot
    this.attachShadow({mode: 'open', customElements: myRegistry});

    // Use the scoped element creation APIs to create elements:
    const myFoo = this.shadowRoot.createElement('my-foo');
    this.shadowRoot.appendChild(myFoo);

    // myFoo is now associated with the scope of `this.shadowRoot`, and registy
    // of `myRegistry`. When it creates new DOM, is uses `myRegistry`:
    myFoo.innerHTML = `<my-bar></my-bar>`;
  }
}

Questions

  • What happens to existing upgraded elements when an overriding definition is added to a child registry?

    The simplest answer is that elements are only ever upgraded once, and adding a new definition that's visible in an element's scope will not cause a re-upgrade or prototype change.

  • Should classes only be allow to be defined once, across all registries?

    This would preserve the 1-1 relationship between a class and a tag name and the ability to do new MyElement() even if a class is not registered in the global registry.

    It's easy to define a trivial subclass if there's a need to register the same class in different registries or with different names.

  • Should registries inherit down the tree-of-trees by default, or only via the parent chain of CustomElementRegistry?

    Inheriting down the DOM tree leads to dynamic-like scoping where definitions can change depending on your position in the tree. Restricting to inheriting in CustomElementRegistry means there's a fixed lookup path.

  • Should the registry of a ShadowRoot be final?

  • Is Element.prototype.scope neccessary?

    It requires all elements to remember where they were created, possibly increasing their memory footprint. Scopes could be dynamically looked up during new DOM creation via the getRootNode() process instead, but this might slow down operations like innerHTML.

  • How does this interact with the Template Instantiation proposal?

    With Template Instantiation document.importNode() isn't used to create template instances, but HTMLTemplateElement.prototype.createInstance(). How will that know which scope to use? Should it take a registry or ShadowRoot?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment