[w3c/webcomponents] Scoped Custom Element Registries (#716)

Since #488 is closed, I thought I'd open up a new issue to discuss a relatively specific proposal I have for Scoped Custom Element Registries.

# 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 `CustomElementRegistry`s 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`

`ShadowRoot`s 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

```js
// 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`?

/cc @domenic @rniwa @hayatoito @TakayoshiKochi


-- 
You are receiving this because you are subscribed to this thread.
Reply to this email directly or view it on GitHub:
https://github.com/w3c/webcomponents/issues/716

Received on Friday, 8 December 2017 20:05:47 UTC