[proposal] Register custom elements onto ShadowDOM roots


#1

I mentioned in a public-webapps thread how Custom Elements coupled with ShadowDOM, along with a new ability to register Custom Elements on a per-shadow-root basis would be a good way to have Custom-Element-based “web components” that can encapsulate HTML/DOM similar to how React components encapsulate “HTML” (JSX):

Note, this opening description of the proposal is deprecated in favor of the new description in the comment below.

Previous ideas have talked about being able to register elements on a per-document basis, but that doesn’t seem to help things be more modular and componentized. Shadow roots can be the form of encapsulation for Custom Elements in a similar manner as how JSX/render() encapsulates “HTML” (“DOM”) for a React component.

Now, if we could register elements on a per-shadow-root basis, then we’d effectively have encapsulated HTML.

From the email thread, here’s what a small component might look like:

//  --------------- HandyForm.js
import AwesomeButton from './AwesomeButton'
import { FancyForm, FancyInput } from './FancyForm'

export default
class HandyForm extends HTMLElement {
    constructor() {
        this.root = this.createShadowRoot()
        this.root.registerElement('cool-button', AwesomeButton)
        this.root.registerElement('fancy-form', FancyForm)
        this.root.registerElement('fancy-input', FancyInput)

        const frag = document.createDocumentFragment()
        frag.innerHTML = `
            <div>
                <fancy-form>
                    <fancy-input type="text" /> <!-- give us self-closing custom elements, pleeeease w3c -->
                    <cool-button type="submit"></cool-button>
                </fancy-form>
            </div>
        `
        this.root.appendChild(frag)
    }

    static get observedAttributes() { return [ ... ] }

    connectedCallback() { ... }
    disconnectedCallback() { ... }
    attributeChangedCallback() { ... }
}

//  --------------- app.js
import HandyForm from './HandyForm'

// elements registered on the document won't cross into shadow roots
document.registerElement('handy-form', HandyForm)
document.body.appendChild(document.createElement('handy-form'))

As you can see here, the pattern would be that a component author would utilize Custom Elements and ShadowDOM together, along with a new ability to register elements for that specific shadow root (and only for that shadow root, it won’t leak to the outer DOM scope). This effectively allows the component author to define which HTML elements map to which classes inside the component (similar to how JSX elements map to React classes inside a React component).

As part of general recommendations, sites like MDN, MSDN, developer.chrome.com, etc, would recommend that authors of Custom Elements supply end users only the class for the custom element, and that end users register the element using any name they desire. As you see in the example, the end user of the AwesomeButton element chose to register the AwesomeButton class into his/her component’s shadow root as “cool-button”:

        this.root.registerElement('cool-button', AwesomeButton)

Then, the AwesomeButton end user, who is the author of the HandyForm element simply exports the HandyForm class and does not register it onto the document, leaving it up to the end user of HandyForm to decide how to do that and with what name. The pattern is part of the import process. For normal JavaScript, an import statement is usually enough, but for HTML elements, the pattern would be

import SomeElement from 'some-element'
document.registerElement('any-name', SomeElement)
// ...
shadowRoot.registerElement('any-name', SomeElement)

For this all to work nicely, an important rule would be that elements registered at the top level with document.registerElement should not propagate into shadow roots. A new shadow root should start as a clean slate who’s DOM uses only the native elements until Custom Elements are explicitly registered. For exampe:

// --- file1.js
import CustomImageElement from 'somewhere'

document.registerElement('img', CustomImageElement)

// creates a CustomImageElement instance:
const img = document.createElement('img')

document.body.appendChild(img)
img.src = 'path/to/image.png'
// --- file2.js
const el = document.querySelector('.foo')
const root = el.createShadowRoot()

// creates an HTMLImageElement instance, not affected by
// the previous call to document.registerElement:
const img = root.createElement('img')

root.appendChild(img)
img.src = 'path/to/image.png'
// --- file3.js
import OtherImageClass from 'other-place'

const el = document.querySelector('.bar')
const root = el.createShadowRoot()
root.registerElement('img', OtherImageClass)

// creates an OtherImageClass instance:
const img = root.createElement('img')

root.appendChild(img)
img.src = 'path/to/image.png'
// --- file4.js
import CustomImageElement from 'somewhere'

const path = 'path/to/image.png'
const el = document.querySelector('.bar')
const root = el.createShadowRoot()
root.registerElement('img', CustomImageElement)

// creates a CustomImageElement instance:
root.innerHTML = `
    <div>
        <img src="${path}">
        </img>
    </div>
`

What do you think of this idea?


[Custom Elements] Not requiring hyphens in names
Relaxing restrictions on custom attribute names
#2

Slightly off-topic, but:

This was discussed in the HTML github and the conclusion seems to be that making custom elements pay attention to the self-closing syntax is probably fine, it just needs implementor interest before they’re willing to spec a change like that.


#3

I updated my example, moving render logic to a render() method, so that example is more similar to a React component (which also has a render() method):

import AwesomeButton from './AwesomeButton'
import { FancyForm, FancyInput } from './FancyForm'

export default
class HandyForm extends HTMLElement {
    constructor() {
        this.root = this.createShadowRoot()
        this.root.registerElement('cool-button', AwesomeButton)
        this.root.registerElement('fancy-form', FancyForm)
        this.root.registerElement('fancy-input', FancyInput)

        this.frag = this.root.createDocumentFragment()

        this.someValue = 0
        this.render()
    }

    render() {
        this.frag.innerHTML = `
            <div>
                <fancy-form>
                    <fancy-input type="text" value="${this.someValue}" /> <!-- give us self-closing custom elements, pleeeease w3c -->
                    <cool-button type="submit"></cool-button>
                </fancy-form>
            </div>
        `
        if (this.root.hasChildNodes())
            this.root.removeChild(this.root.firstChild)
        this.root.appendChild(frag)
    }

    static get observedAttributes() { return [ ... ] }

    connectedCallback() {
        this.interval = setInterval(() => { this.someValue++; this.render() }, 1000)
    }

    disconnectedCallback() {
        clearInterval(this.interval)
    }

    attributeChangedCallback() { ... }
}

With some imagination, we can imagine that the AwesomeButton, FancyForm, and FancyInput components might be made using this same pattern, and might register they’re own internal elements.


#5

Take a look at react-standalone which is a custom elements implementation using React, however it is framework agnostic, thus truly reusable and interoperable.


#6

I wrote a better description of the proposal in the Chromium tracker, so posting here for reference:

We currently have the ability to register Custom Elements onto the top-level document by doing

document.registerElement('any-name', SomeElementClass)
// ^ this currently ignores the class' constructor, so leave the constructor empty for now.

This allows us to define a class that encapsulates the behavior of our Custom Element, which is awesome!!

But, there are some limitations of this when compared to using React instead of the native Custom Elements API. First, let me describe what React has that makes it powerful:

JSX in React encapsulates “HTML” (but keep in mind JSX “is just JavaScript” as JSX compiles to plain JS) on a per-component basis. This is powerful in React because the “custom elements” in React are classes that are imported and contained within the React component’s JavaScriptlexical scope. For example:

import React from 'react'
import AwesomeButton from 'AwesomeButton'

export default
class MyForm extends React.Component {
    constructor() {
        this.value = "hello form"
    }
    render() {
        return (
            <form>
                <input type="text" value={this.value} />
                <AwesomeButton type="submit">Submit</AwesomeButton>
            </form>
        )
    }

    componentDidMount() { ... }
    componentWillUnmount() { ... }
    componentWillReceiveProps() { ... }
}

What’s important here is that AwesomeElement is lexically scoped to the component thanks to how JavaScript works. Some other file can not use AwesomeButton unless that other file also imports AwesomeButton.

This is much better than using globals!!

The problem with the current Custom Elements API: everything is a global! Custom Elements are currently registered globally for the entire web app, via document.registerElement().

I’d like to propose a possible solution that will introduce the ability for custom element authors to scope imported custom elements to their components (achieving an effect of encapsulation similar to React components): allow the registration of Custom Elements onto ShadowDOM roots. Before showing how the custom element “web component” encapsulation would work, first let’s see how registering a Custom Element onto a shadow root would work:

// --- file4.js
import CustomImageElement from 'somewhere'

const path = 'path/to/image.png'
const el = document.querySelector('.bar')
const root = el.createShadowRoot()
root.registerElement('img', CustomImageElement)

// The 'img' tag creates a CustomImageElement instance:
root.innerHTML = `
    <div>
        <img src="${path}">
        </img>
    </div>
`

(Note, as we can see in the example, I am also indirectly proposing that we be allowed to override native elements; in this case the IMG element is overridden.)

Here’s one more example using the imperative form of element creation and following the hyphen-required rule:

import CustomImageElement from 'other-place'

const el = document.querySelector('.bar')
const root = el.createShadowRoot()
root.registerElement('img', CustomImageElement)

// creates a CustomImageElement instance:
const img = root.createElement('img')

root.appendChild(img)
img.src = 'path/to/image.png'

In both of the last two examples, a Custom Element is registered on a shadow root. The registration is only valid within the DOM of those shadow roots and the registration does not escape the shadow root, thus the shadow root encapsulates the registration. If the shadow root contains a sub-shadow-root, then the sub-shadow-root is not affected by the parent shadow root’s registration. Likewise, registrations on the document do not propagate into shadow roots. For example:

import CustomImageElement from 'somewhere'
document.registerElement('img', CustomImageElement)

// ...
// creates an instance of HTMLImageElement despite the registration on the
// document:
shadowRoot.appendChild(shadowRoot.createElement('img'))

(Note, I’m also implying here that the createElement method would need to exist on shadow roots, which makes sense if shadow roots will have their own custom element registrations.)

Now, let me show how component encapsulation would work with web components made with the paring of Custom Elements and ShadowDOM (providing encapsulation of HTML/DOM similarly to React components). In the previous React example, AwesomeButton is a component that is defined in a similar fashion to MyForm class: it imports any components that it needs and uses them within the lexical scope of the module that its definition lives in. In the Custom Element API, we don’t have the luxury of the JavaScript lexical scope passing into our markup (well, at least not with some sophisticated and possibly mal-performing template-string tagging hackery).

So, let’s get down to business: let’s see what a Custom Element “component” would look like. Let’s recreate the React-based MyForm example above, but this time using Custom Elements + ShadowDOM coupled with the idea that we can register Custom Elements onto ShadowDOM roots:

import AwesomeButton from 'AwesomeButton'

export default
class MyForm extends HTMLElement {
    constructor() {
        this.root = this.createShadowRoot()
        this.root.registerElement('awesome-button', AwesomeButton)

        this.frag = this.root.createDocumentFragment()

        this.value = 'hello form'
        this.render()
    }

    // A naive render function that has no diffing like React. We could use
    // React here for that.
    render() {
        this.frag.innerHTML = `
            <div>
                <form>
                    <input type="text" value="${this.value}" /> <!-- give us self-closing custom elements, pleeeease w3c -->
                    <awesome-button type="submit">Submit</awesome-button>
                </form>
            </div>
        `
        if (this.root.hasChildNodes())
            this.root.removeChild(this.root.firstChild)
        this.root.appendChild(frag)
    }

    connectedCallback() { ... }
    disconnectedCallback() { ... }
    attributeChangedCallback() { ... }
}

What we can see in this example is that we’ve effectively encapsulated the meaning of <awesome-button> inside of our Custom Element component. Instead of using JavaScript’s lexical scoping, we’ve used the encapsulation of our component’s shadow root by registering awesome-button onto it. This gives freedom to the web component developer; allowing the developer to determing what names are used for Custom Elements that are used withing the developer’s own custom-element-based component.

An idea like this, whereby the registration of an element can be encapsulated within a component, will be a great way to increase modularity in the web platform.

What do you think of this idea?


#7

Ultimately, this requires every element-creation API to accept a context argument, so it can know which set of custom elements constructors to use. If you accidentally omit it, it would default to the global scope.

In particular, that means createElement() and createDocumentFragment() (the latter so it can tell what constructors to use when you set innerHTML) need to have context arguments, or maybe automatically inherit one from the shadow root you call them from. (That would mean your example is broken, since you’re using the document’s createDocumentFragment() - it would pull from the document root’s set of elements, not the ones you registered to MyForm.)


#8

This is true! The createElement() method’s context would indeed be the shadow root that it’s called from. As for the createDocumentFragment() method, the easiest solution to that would be to be able to call createDocumentFragment() on a shadow root as well. I’ll update my examples. Inserting a document fragment created from one shadow root into another shadow root would throw an error.

I’m in love with this idea of calling createElement/DocumentFragment (perhaps some other methods we haven’t talked about too) on shadow roots, and the premise of encapsulation that this provides. I’m also open to other ideas on how elements can be encapsulated.

EDIT: I updated the examples so that the relevant parts of the Custom Element definitions look something like this:

        this.root = this.createShadowRoot()
        this.frag = this.root.createDocumentFragment()

where this.frag can only be used with (appended to) this.root. I think that would be the best way (better than something like document.createDocumentFragment(shadowRoot) with shadowRoot as context).