Skip to content

Instantly share code, notes, and snippets.

@EisenbergEffect
Last active May 6, 2024 12:40
Show Gist options
  • Save EisenbergEffect/8ec5eaf93283fb5651196e0fdf304555 to your computer and use it in GitHub Desktop.
Save EisenbergEffect/8ec5eaf93283fb5651196e0fdf304555 to your computer and use it in GitHub Desktop.
HTML Modules and Declarative Custom Elements Proposal

HTML Modules and Declarative Custom Elements Proposal

Author Status Last Updated
Rob Eisenberg Work in Progress May 6, 2024

This document outlines a potential HTML Modules and Declarative Custom Elements (DCE) proposal, with the intention of bringing HTML Modules and DCEs as an official W3C proposal in the future.

Before submission, this document will likely be split into several distinct proposals. The most obvious subdivisions are HTML Modules, Templating, and Declarative Custom Elements. For now, the author has chosen to keep these feature areas together so that readers can see the interplay between them and better understand the full picture without having to flip back and forth between multiple documents, or read various sections of said documents out of order.

In conjunction with the design of this proposal, a full working prototype has been built, implemented as an HTML-to-ESM compiler. Throughout this proposal, a version of the prototype compiler's output will be used to show how declarative constructs desugar to equivalent ESM code that uses today's imperative APIs.

Note

The compiler is not available in a public repo at this time. Making it public will depend on interest from the community and proper resources to support the work. Both are TBD.

Status

At present, this document represents a rough, candidate proposal for HTML Modules and Declarative Custom Elements (DCE). It has not officially been submitted, nor does it have anyone's endorsement (except the author's).

For years, many in the community have expressed a desire for declarative custom elements and HTML modules, yet no real progress has been made. One of the biggest reasons seems to be that no one has put together a real proposal nor attempted to answer the open questions. This document represents the author's attempt to "get the ball rolling".

Background

The Web Components journey started over a decade ago. Throughout these years, various proposals have come and gone, resulting in the almost entirely imperative JS API that we have today. Unfortunately, many of the original, more declarative ideas for Web Components went by the wayside, despite a consistent interest in declarative approaches.

This proposal seeks to re-imagine a declarative model for Web Components, given today's shipped (and future shipping) browser standards. This is a large task, and so this proposal is also large, containing many parts. That said, it shouldn't be read as an "all or nothing" proposal nor assumed that every feature described here must be shipped in v1. Many developers would be happy to have some type of declarative model for Web Components, even if it is limited in the beginning. As such, towards the end of this proposal, a rough "plan of attack" with individually valuable milestones is presented. The hope being that this would allow browser vendors to incrementally implement and ship features, providing value in the short term, while staying aligned to a longer term, consistent vision.

Motivation for Standardization

While much can be done with imperative APIs, the fact remains that JavaScript is required for Web Components, and sometimes a lot or even quite tricky JavaScript is required. Here are a few motivating factors for a declarative standard:

  • Web developers would like to be able to use Web Components in environments where JavaScript is disabled.
  • Content creators would like to be able to use Web Components in plugin environments where products (e.g. a CMS) only allow authoring in HTML and CSS, not JavaScript.
  • Non-JS Web creators would like a way to build Web Components that lets them utilize their HTML and CSS skills, without having to drop down into JS that they may not be comfortable with.
  • Web Developers would like to "fall into the pit of success" when authoring Web Components.
    • Today, a properly functioning Web Component is quite challenging to write, and requires a lot of code in Vanilla JS. Most people cannot do it without a library.
  • Fullstack Developers would like components to be renderable on any server stack and resumable on the client, without needing a server-specific client library.
    • This is extremely tricky and requires a lot of code to accomplish in Vanilla JS. It is not practical without a fairly advanced library. Most libraries today require a specific server to make this work as well.
  • Frontend Engineers would like to ship as little JavaScript as possible, especially in startup critical scenarios.
  • Design System Engineers want a way to distribute a component or set of components in a single file without dependencies.
  • Design System Engineers would like components to be cached across pages, since an entire site or domain may use the same set of core components.
  • Tooling vendors would like a component format that is "toolable", enabling bidirectional editing of component source.
    • i.e. Open a component file in a tool and make edits. See those changes reflected in the app. Make changes directly in the code. See those changes reflected directly in the tool. No loss of information in either direction.

While some of these use case can (and should) be enabled by improvements to imperative JavaScript APIs, many of them are not possible without a declarative model. Additionally, a recent survey of over 400 people in the author's network yielded the following community interest in a declarative model for Web Components:

  • 26.3% of respondents indicated that a declarative model is the way that Web Components should have been standardized from the beginning.
  • An additional 33.7% of respondents indicated that they wanted to use a declarative model for Web Components.
  • 27% of respondents had interest and would use a declarative model for Web Components, depending on the details.
  • Only 12.9% of respondents had no interest in declarative Web Components.

Altogether, about 60% of all respondents were anxious to have and use a declarative API for Web Components now and about 87% of respondents were interested in some way, depending on the details of the API. Such a large community interest seems to indicate that at least exploring a potential standard is in order.

Survey Notes

I asked those who responded "maybe" or "no" to provide me with some additional explanation behind their response. This turned out to be quite interesting, indicating that some "maybe" responses were more likely to be "yes" and that some "no" responses were more like "maybe". Furthermore, some of the "no" responses came from people who weren't Web Developers and had no interest in the Web Platform, skewing the numbers a bit. Based on these additional insights, the community signal is likely even more positive than the numbers above show.

Design Goals

Requirements

To enable the above use cases, this proposal sets forth the following goals:

  • Define a declarative HTML Module format that:
    • Enables declaring and exporting custom elements, templates, styles, and html fragments.
    • Supports importing exported resources into HTML documents and other HTML Modules, as well as first class integration with ES Modules.
    • Within certain constraints, can have its binary representation cached and reused across pages.
  • Define a declarative format for custom elements that:
    • Enables a subset of Web Component scenarios to be accomplished without any JavaScript code.
    • Simplifies common custom element patterns.
      • e.g. boolean attributes, attr/property reflection, context
    • Prevents mistakes common in imperative API usage.
      • e.g. correctly handling properties set prior to element upgrade
    • Surfaces all current imperative APIs related to custom elements in a declarative form.
      • e.g. form association, element internals, shadow dom mode, etc.
    • Supports optional script to augment declarative capabilities.
    • Is safe by default.

Nice to Haves

It would be nice if declarative custom elements had performance advantages over today's imperative approaches. It seems as if this should be possible, at least within certain constraints. However, it is the feeling of this proposal's author that the above features would be valuable even if the performance is not better than what can be accomplished by hand coding the equivalent JavaScript code.

Non Goals

It is explicitly a non-goal of this proposal to enable every conceivable custom element to be authored without JS. This proposal seeks to define declarative mechanisms for existing imperative APIs and support common authoring patterns, while enabling developers to "break out" of the declarative model and add imperative code as needed.

Related Standards

Prior Art

API

The idea for how HTML Modules and Declarative Custom Elements (DCE) could be written is presented below. This section begins with a general explanation of how HTML Modules work, both in terms of exports and imports. It then proceeds to show specific examples for each of the HTML module resource types. With that as a foundation, the conversation moves on to explore DCEs in greater depth, starting with the simplest example and then adding features one at a time until the full capability set is built up.

HTML Modules

An HTML Module is a new type of HTML "document" that contains exportable resource definitions intended to be imported by an HTML document, other HTML Modules, and/or ES Modules. The verbose form of an HTML module is as follows:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <resource-1>...</resource-1>
    <resource-2>...</resource-2>
    <resource-3>...</resource-3>
  </body>
</html>

However, HTML Modules can also be declared as a "bare" set of resources, for an improved authoring experience:

<resource-1>...</resource-1>
<resource-2>...</resource-2>
<resource-3>...</resource-3>

Resources can be exported by applying the export attribute. If an id is provided, the export will be named, otherwise it will become the default export.

<!--Default export-->
<resource-1 export>...</resource-1>

<!--Named export as Resource2-->
<resource-2 export id="Resource2">...</resource-2>

<!--Not exported-->
<resource-3>...</resource-3>

Note

Script within an HTML Module can look up resources via import.meta.document.getElementById(...).

Resources can be imported into an ES Module using import assertions as follows:

import Resource1, { Resource2 } from "./my-module.html" with { type: "html" };

They can also be imported into an HTML document:

<import from="./my-module.html#Resource2">
Designer's Note

I went through several iterations of how to handle exports while building my prototype. For example, I started out with something like export="Resource2". However, I found that this wasn't sufficient for all use cases. The instruction to export a thing needed to be independent of the ability to add an id. For example, there are use cases for having non-exported templates within a module that you might want to use in some way by simply locating it via import.meta.document.getElementById("myTemplate").

Any HTML can be exported from a module. Nodes are exported as a DocumentFragment, except for the following types:

  • <template>
  • <style>
  • <element> (new)
  • <registry> (new)

Each of these types is exported as itself (not wrapped in a fragment). The following sections will provide further examples of exporting and importing.

Open Questions

  • [Technical] Do we need a new doctype?
  • [Technical] How should an HTML Module's base URL be handled? Is the base URL inherited based on where it is imported from? Should we allow a <base> element in the head of the module to override default inheritance?
  • [Technical] All the details around HTML Module caching need to be worked out. Can we lean on Firefox's past experience to help drive this?
  • [Capabilities] Are there any other types of nodes that should be exportable from an HTML Module in a special way and not as a fragment? For example, what about SVG symbol elements? Or should we not export anything as a fragment, but just as is?
  • [Capabilities] Should it be possible to declare the export name, independently of the id? For example, we could support that if export was added with no value, then the id would be used as the export name. But if a value was provided for export then that would be the export name, with the id being useable for internal lookup. But do we actually need something like that?
  • [Bikeshedding] Should the <import> element use a from attribute to match ES Module syntax or should it use a src attribute to match other HTML elements like <script> and <img>? Should we use a <link> element with a new rel type instead of an <import> element?

Declaring, Exporting, and Importing DCEs

Custom elements can be declared in any of following HTML contexts:

  • The document
  • An HTML Module
  • A <registry>
    • See "Declaring, Exporting, and Importing Non-DCE Resources"

When a custom element is declared in the document, it is also defined in the document's global custom element registry. Here's an example document that defines a hello-world element and immediately uses it:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>DCE</title>
  </head>
  <body>
    <element tag="hello-world"></element>

    <hello-world></hello-world>
  </body>
</html>

In the above example, we use the element tag to declare a new custom element with the tag name "hello-world". Because this DCE is declared directly in the document, it is immediately defined. The above HTML would desugar to something like the following:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>DCE</title>
  </head>
  <body>
    <script>
      customElements.define(
        "hello-world", 
        class extends HTMLElement { }
      );
    </script>

    <hello-world></hello-world>
  </body>
</html>

On the other hand, when a DCE is declared in an HTML Module, it is not defined. Rather, the code that imports the element is responsible for defining it in the registry where it's needed.

Here's the HTML for a hello-world.html module containing a single DCE:

<element export>

</element>

When declared in an HTML Module, this syntax roughly desugars to the following ES Module equivalent:

export default class extends HTMLElement { }

A DCE declared in an HTML Module can then be imported into several different contexts:

  • The document
  • An ES Module
  • A DCE Shadow DOM
    • See "DCE Views"
  • A declarative CE registry.
    • See "Declaring, Exporting, and Importing Non-DCE Resources"

To import the element into the document, we would use the following HTML:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>DCE</title>
  </head>
  <body>
    <import from="./hello-world.html" tag="hello-world">

    <hello-world></hello-world>
  </body>
</html>

Important

By default, all HTML imports in the main document function as if they were a script with the async attribute. They are downloaded in parallel to parsing the page, and executed as soon as the import is available.

To import the element into an ES Module and define it in a registry, we would use this code:

import HelloWorld from "./hello-world.html" with { type: "html" };

const registry = new CustomElementRegistry();
registry.define("hello-world", HelloWorld);

DCEs can also be exported as named exports. Here's an example my-module.html module that exports three DCEs, two of them named, and one of them as the default:

<element export id="NamedElementOne"></element>
<element export id="NamedElementTwo"></element>
<element export></element>

This would desugar to the following:

export class NamedElementOne extends HTMLElement { }
export class NamedElementTwo extends HTMLElement { }
export default class extends HTMLElement { }

In HTML, the NamedElementTwo export would be imported and defined as follows:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>DCE</title>
  </head>
  <body>
    <import from="./my-module.html#NamedElementTwo" tag="my-element">

    <my-element></my-element>
  </body>
</html>

Summary

  • In HTML
    • The <element> tag declares a custom element.
      • When used in the document, the tag attribute provides the tag name under which the element should be defined.
      • When used in an HTML Module, the export attribute indicates that the DCE should be exported.
      • When used in an HTML Module, the id attribute specifies the DCE as a named export, rather than the default export.
    • The <import> tag imports from an HTML or CSS Module.
      • The from attribute provides the module id.
      • The from attribute can include a fragment (#) to indicate a named export instead of the default.
      • The tag attribute provides the tag name under which the DCE should be defined.
      • By default, imports in the document are handled the same as a script tag with an async attribute.
      • The defer attribute can be added to an import to defer defining the imported element until after the page has finished parsing.
  • In JavaScript
    • The with { type: "html" } import assertion can be used to import any exports from an HTML Module.

Open Questions

  • [Ergonomics] Could the tag attribute be optional on the <import> element? For example, if an <element> in an HTML Module specifies a tag, could that be picked up on <import> if no tag attribute is provided there? This might improve ergonomics for a common scenario, but would it introduce the possibility of bugs? This change would mean the following:
    • A tag is required on all in-document <element> declarations.
    • A tag is optional for HTML Module <element> declarations.
    • When using <import>, the tag attribute is only required if the element being imported doesn't declare a tag.
    • When using <import>, the tag can always override what is declared on the element being imported.
    • When using <import>, if no tag is provided on either the <import> or the <element>, then an error is thrown.
  • [Bikeshedding] Should the <import> element tag attribute be named as instead, to match ES Module syntax? e.g. <import from="foo.html" as="my-foo"> If we did this, would we also want to rename the tag attribute on <element>? Would this attempt at consistency make sense on <element> or would it add confusion?

Declaring, Exporting, and Importing Non-DCE Resources

In addition to DCEs, HTML Modules should also be able to declare and export the following HTML resources:

  • <template>
  • <style>
  • <registry>
  • DocumentFragment

In each case, the export and id attributes are used the same as in the case of a DCE. Here's an example my-resources.html module that declares a template, some styles, and a shared header fragment:

<template export id="pricingCard">
  <div class="pricing-card">
    <div class="header">
      <h4 class="name"></h4>
    </div>
    <div class="body">
      <h1 class="title"></h1>
      <ul class="features">
      </ul>
      <button type="button" class="subscribe-action"></button>
    </div>
  </div>
</template>

<style export id="sharedHeaderStyles">
  .shared-header {
    display: flex;
    justify-content: center;
  }
</style>

<header class="shared-header" export id="sharedHeader">
  <ul>
    <li><a href="./home">Home</a></li>
    <li><a href="./features">Features</a></li>
    <li><a href="./pricing">Pricing</a></li>
    <li><a href="./faq">FAQs</a></li>
    <li><a href="./about">About</a></li>
  </ul>
</header>

The above HTML would desugar to the following ES module:

const pricingCard = document.createElement("template");
pricingCard.innerHTML = `
  <div class="pricing-card">
    <div class="header">
      <h4 class="name"></h4>
    </div>
    <div class="body">
      <h1 class="title"></h1>
      <ul class="features">
      </ul>
      <button type="button" class="subscribe-action"></button>
    </div>
  </div>
`;

const sharedHeaderStyles = new CSSStyleSheet();
sharedHeaderStyles.replaceSync(`
  .shared-header {
    display: flex;
    justify-content: center;
  }
`);

const template = document.createElement("template");
template.innerHTML = `
  <header class="shared-header">
    <ul>
      <li><a href="./home">Home</a></li>
      <li><a href="./features">Features</a></li>
      <li><a href="./pricing">Pricing</a></li>
      <li><a href="./faq">FAQs</a></li>
      <li><a href="./about">About</a></li>
    </ul>
  </header>
`;

const sharedHeader = document.adoptNode(template.content);

export { pricingCard, sharedHeaderStyles, sharedHeader };

These can be imported into an ES Module as expected:

import { 
  pricingCard, 
  sharedHeaderStyles,
  sharedHeader
} from "./my-resources.html" with { type: "html" };

The <registry> type is a declarative way to create a CustomElementRegistry instance. This allows an entire registry of elements to be imported into the document, another registry, or a DCE Shadow DOM (see below).

Here's an example of declaring a <registry>:

<registry export>
  <import from="./my-element.html" tag="your-element">
  <import from="./my-module.html#NamedElementTwo" tag="element-two">
  <import from="./your-registry.html">
  
  <element tag="inline-element"></element>
</registry>

The above HTML would desugar to the following ES module:

import MyElement from "./my-element.html" with { type: "html" };
import { NamedElementTwo } from "./my-module.html" with { type: "html" };
import yourRegistry from "./your-registry.html" with { type: "html" };

const registry = new CustomElementRegistry();

registry.define("your-element", MyElement);
registry.define("element-two", NamedElementTwo);
registry.import(yourRegistry);
registry.define("inline-element", class extends HTMLElement {});

export default registry;

Important

In order to make the above code work, a new import(registry: CustomElementRegistry): void method would need to be added to the CustomElementRegistry type. This API would allow importing all the definitions contained in one registry into another registry.

Open Questions

  • [Capabilities]/[Ergonomics] Since today templates need JS to be useful, do we need a declarative way to import and use them in HTML to go along with HTML Modules? What would that look like? For example, do we need to introduce a new element, such as <partial src="..."> that imports and renders the template in place? Should this element support fallback rendering? Should this also work with exported fragments? If rendering a template, how do we provide the data, assuming it supports bindings?

DCE Host Attributes

Previously, we've seen that an element can be declared as follows:

<element tag="my-element" export id="MyElement">
</element>

The above example shows the three "host attributes" that we've previous discussed: tag, export and id. These are not the only attributes that can be used on the host though. Additional attributes can be used to take advantage of ElementInternals and Forms APIs.

  • formassociated - Adding this attribute makes the element a form associated custom element, essentially adding the static formassociated = true to the element declaration.
  • role and aria- - When adding these attributes to the host, the ElementInternals ARIA Mixin API will be used.

Here's an example that combines several of the attributes:

<element formassociated role="button" export id="MyButton">

</element>

And here is the ES Module that this code would roughly desugar to:

class MyButton extends HTMLElement {
  static formAssociated = true;
	
  constructor() { 
    super();
    const internals = this.attachInternals();
    internals.role = "button";
  }
}

export { MyButton };

Open Questions

  • [Capabilities] Are there any other attributes that should be on the element host? Note: I have explicitly not included any attributes related to Shadow DOM. See "DCE Views" below for details.

DCE Views

So far, the DCEs we've looked at aren't very valuable. They can neither render HTML nor provide behavior. So, let's now turn our attention to basic rendering.

Taking our hello-world component as an example, let's have it actually render the text "Hello World!".

<element export id="HelloWorld">
  <view>
    Hello World!
  </view>
</element>

The above HTML roughly desugars to the following:

const template = document.createElement("template");
template.innerHTML = `
 Hello World!
`;

const fragment = document.adoptNode(template.content);

class HelloWorld extends HTMLElement {
  #initialized = false;
	
  constructor() { 
    super();
    this.attachShadow({ mode: "open" });
  }
	
  connectedCallback() {
    if (!this.#initialized) {
      const view = fragment.cloneNode(true);
      this.shadowRoot.appendChild(view);
      this.#initialized = true;
    }
  }
}

export { HelloWorld };

By default, the <view> element does the following:

  • Creates a template to be used across all instances of the element.
  • Attaches an open mode shadow root to the element.
  • Clones and appends the template to the shadow root.

Important

Hopefully the benefits of a declarative model are beginning to show themselves now. Even with a simple custom element, the amount of code is significantly reduced in the declarative version. Furthermore, common mistakes are prevented, such as forgetting to import/adopt the template's content before cloning.

While the default behavior is to render to an open shadow root, that is not the only option. The shadowrootmode attribute can be used to specify any of three options:

  • open (default) - Attaches a shadow root with mode: "open".
  • closed - Attaches a shadow root with mode: "closed".
  • none - Does not attach a shadow root. In this case, the template will be appended directly to the host element.

Here's an example with shadowrootmode="none":

<element export id="HelloWorld">
  <view shadowrootmode="none">
    Hello World!
  </view>
</element>

Which roughly desugars to:

const template = document.createElement("template");
template.innerHTML = `
 Hello World!
`;

const fragment = document.adoptNode(template.content);

class HelloWorld extends HTMLElement {
  #initialized = false;
	
  connectedCallback() {
    if (!this.#initialized) {
      const view = fragment.cloneNode(true);
      this.appendChild(view);
      this.#initialized = true;
    }
  }
}

export { HelloWorld };
Designer's Note

This is again another area where I went through several iterations in my prototype. I initially started out with a shadowroot element. However, this didn't make sense when rendering to the light dom. An alternative would have been to also support a lightroot element for that scenario, but that seemed very clunky. A template element was avoided because a shadowrootmode attribute would create a DSD, which isn't what we want. Ultimately, I landed on a view element because this seemed to clearly state the element's intent, allow for both light and shadow scenarios, avoid clashes with DSD, and is broadly understood across the industry.

In addition to shadowrootmode, there are several other attributes that can be places on the view element.

  • slotassignment - This determines the slot assignment mode for shadow dom. The default is named in accordance with the existing imperative API. It can also be set to a value of manual.
  • delegatesfocus - This is a boolean attribute that determines whether the shadow root should be configured to delegate focus. It is false by default, in accordance with existing imperative APIs.
  • scopedregistry - This boolean attribute indicates that a scoped custom element registry should be created for the shadow root. Only custom elements explicitly imported into the shadow root will be visible. This is false by default, making all globally defined elements visible within the shadow root. This matches existing imperative API behavior.
  • shadowrootreferencetarget and shadowrootreferencetargetmap - Pending standardization of "Reference Target for Cross-root ARIA", both of these attributes should be supported.

A <view> element supports importing resources from other HTML Modules, of particular import when using the scopedregistry setting. Using the same <import> element as previously discussed, we can import CSS module scripts, a single custom element from an HTML Module, and an entire registry of elements defined in an HTML Module. Here's what that would look like:

<element export id="HelloWorld">
  <view scopedregistry>
    <import from="./typography.css">
    <import from="./my-element.html" tag="your-element">
    <import from="./design-system.html">

    <h2>Hello World!</h2>
    <your-element></your-element>
    <design-system-button>Click Me!</design-system-button>
  </view>
</element>

Desugaring this to a rough ESM equivalent is as follows:

import typography from "./typography.css" with { type: "css" };
import MyElement from "./my-element.html" with { type: "html" };
import designSystem from "./design-system.html" with { type: "html" };

const registry = new CustomElementRegistry();
registry.define("your-element", MyElement);
registry.import(designSystem);

const template = document.createElement("template");
template.innerHTML = `
  <h2>Hello World!</h2>
  <your-element></your-element>
  <design-system-button>Click Me!</design-system-button>
`;

const fragment = document.adoptNode(template.content);

class HelloWorld extends HTMLElement {
  #initialized = false;
	
  constructor() { 
    super();
    this.attachShadow({ mode: "open", registry });
    this.shadowRoot.adoptedStyleSheets.push(typography);
  }
	
  connectedCallback() {
    if (!this.#initialized) {
      const view = fragment.cloneNode(true);
      this.shadowRoot.appendChild(view);
      this.#initialized = true;
    }
  }
}

export { HelloWorld };

Note

Importing a registry or an element into a shadow root that does not have a scoped registry results in those elements being defined in the global registry, if they are not already defined under the specified tag name.

Open Questions

  • [Technical] Is the above behavior for element/registry import into non-scoped shadow dom correct? For example, an error could be thrown when attempting to import elements/registries into non-scoped shadow dom and light dom. What behavior is most desirable and intuitive?

DCE Styles

Encapsulated styles for a custom element can be declared as simply as including one or more <style> elements as a children of <element>:

<element export id="HelloWorld">
  <style>
    :host {
      color: red;
    }
  </style>

  <view>
    Hello World!
  </view>
</element>

This roughly desugars to the following ES Module:

const styles = new CSSStyleSheet();
styles.replaceSync(`
  :host {
    color: red;
  }
`);

const template = document.createElement("template");
template.innerHTML = `
 Hello World!
`;

const fragment = document.adoptNode(template.content);

class HelloWorld extends HTMLElement {
  #initialized = false;
	
  constructor() { 
    super();
    this.attachShadow({ mode: "open" });
  }
	
  connectedCallback() {
    if (!this.#initialized) {
      const view = fragment.cloneNode(true);
      this.shadowRoot.appendChild(view);
      this.shadowRoot.adoptedStyleSheets.push(styles);
      this.#initialized = true;
    }
  }
}

export { HelloWorld };

When an <element> with shadowrootmode="none" is connected, then the following steps are taken instead of what's portrayed in the code above:

  1. Call getRootNode() on the host element.
  2. Check to see if the root node's adoptedStyleSheets already includes the style sheet.
  • If yes, increment a counter indicating how many downstream components are currently dependent on the styles.
  • If no, push the styles into the adoptedStyleSheets and set up a counter to track dependencies, initializing it to 1.

When an <element> with styles and shadowrootmode="none" is disconnected, the following steps are taken:

  1. Call getRootNode() on the host element.
  2. Check to see if the root node's adoptedStyleSheets already includes the style sheet.
  • If yes, decrement a counter indicating how many downstream components are currently dependent on the styles. If the counter reaches 0, remove the style sheet.
  • If no, do nothing.

DCE Attributes

So far, we've looked at DCEs that have no way of receiving input. Let's address that shortcoming by adding attributes to our declarative model.

<element export id="SayHello">
  <attr name="greeting">Hello</attr>

  <view>
    {{this.greeting}} World!
  </view>
</element>

For now, let's set aside the templating syntax and focus on the <attr> element. A partial code-listing of the desugared version should help demonstrate what the <attr> element is doing for us:

const template = createATemplateWithBindingsSomehow();

class HelloWorld extends HTMLElement {
  static observedAttributes = ["greeting"];
  #view = null;
  #greeting = new Signal();

  get greeting() { 
    let value = this.#greeting.get();

    if (value === void 0) {
      value = this.getAttribute("greeting") ?? "Hello";
      Signal.untrack(() => this.#greeting.set(value));
    }

    return value;
  }

  set greeting(value) { 
    this.#greeting.set(value);
  }

  constructor() { 
    super();
    this.attachShadow({ mode: "open" });
    
    if (this.hasOwnProperty("greeting")) {
      const value = element["greeting"];
      delete element["greeting"];
      Signal.untrack(() => this.#greeting.set(value));
    }
  }
	
  connectedCallback() {
    if (this.#view === null) {
      this.#view = createABindableViewSomehow(template);
      this.#view.appendTo(this.shadowRoot);
    }

    bindTheViewSomehow(this.#view);
  }

  disconnectedCallback() {
    unbindTheViewSomehow(this.#view);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case "greeting":
        this.#greeting.set(newValue);
        break;
    }
  }
}

export { HelloWorld };

Again, setting aside templating and binding for the moment, let's look at what the <attr> element is doing for us:

  • The static observedAttributes property is created with the string "greeting", coming from the <attr> name's value.
  • The attribute is backed by a Signal field, which will be used to store the attribute's value and enable reactivity. (See the note below on Signals.)
  • By default <attr>creates a JS property to access the attribute. It is given the same name as the attribute.
  • The JS property getter will initialize itself from the attribute, if it has a value, otherwise falling back to the default value provided as content to the <attr> element. When it is initialized, the change (which is really not a change), will not be tracked by the Signal system.
  • By default, the JS property setter will set the value of the signal.
  • When the attribute changes through the attributeChangedCallback, the underlying signal will be updated with the new value.
  • When the custom element upgrades, the constructor will check whether a property value was set prior to upgrade. Because this would shadow the prototype getter/setter, the own property will be deleted, and the underlying signal field will be updated with the value. Because this isn't a real change, just an initialization, the signal set will be untracked.

The above is fairly representative of what Web Component libraries do today, typically through decorators and metadata. Without such a library, it is easy to get many of these details wrong. In the declarative model, this should all be handled correctly and automatically. The component author just has to indicate that they want an attribute, along with the preferred options.

Important

In order to enable efficient template updates, this proposal recommends that attributes and properties be backed by signals. In particular, the recommendation is to leverage the TC39 Signals Proposal. The code shown here uses that API. See "DCE Templating" for more details on how signals and templates work together.

A summary of the <attr> element is as follows:

  • Use the <attr> element to declare an observed attribute.
  • Use the name attribute to provide the name for the attribute.
  • Use the prop attribute to provide a different name for the property.
  • Use the omitprop attribute to skip generating a property.
  • Use the reflect attribute to reflect property changes back to the attribute on the host.
  • Use the type attribute to specify the attribute type. Known types include string, boolean, and number.
    • The default is type string.
    • A type boolean will result in a boolean attribute.
    • A type number will ensure that the JS property parses attributes to and from numbers correctly.
    • Any other value is ignored. See "Open Questions" below.
  • The content provided between the <attr> element's start and end tags will provide the default value, interpretable based on the type. Boolean attributes always have a default value of false.

Here are a few examples of different <attr> configurations:

<!--
  An observed string attribute named "a" 
  with a JS property named "a" and no default value.
-->
<attr name="a"></attr>

<!--
  An observed string attribute named "b" 
  with no JS property.
-->
<attr name="b" omitprop></attr>

<!--
  An observed number attribute named "b" 
  with a JS property named "d"
  and a default value of 42.
-->
<attr name="c" prop="d" type="number">42</attr>

<!--
  An observed boolean attribute named "e" 
  with a JS property named "e"
  and a default value of false.
  The property will be `true` when 
  the attribute is present and `false` when 
  the attribute is not present. Changes in the property
  will be reflected back to the attribute.
-->
<attr name="e" type="boolean" reflect></attr>
Designer's Note

This is an example of a feature that should be back-ported to the imperative JS API. For example, observedAttributes could take configuration objects in addition to the strings that it supports today:

class MyElement extends HTMLElement {
  static observedAttributes = [
    "a",
    { name: "b", omitprop: true },
    { name: "c", prop: "d", type: Number, value: 42 },
    { name: "e", type: Boolean, reflect: true }
  ];
}

Additionally, whatever API pattern is established here, should also be used for the Custom Attributes proposal.

Open Questions

  • [Capabilities] Should we support any other attributes types besides string, boolean, and number? Should this be extensible so that component authors can provide their own converters? How would this work?
  • [Capabilities] Should the <attr> element also support a reflect attribute that indicates whether or not changes in the property should be reflected back to the attribute? At present, the only option is to reflect changes in both directions.

DCE Properties

Another way for a custom element to receive input is through properties. Let's add properties to our declarative model:

<element export id="MyInput" formassociated>
  <attr name="value" prop="defaultValue"></attr>
  <prop name="value" init="attr">value</attr>

  <view delegatesfocus>
    ...
  </view>
</element>

The example above shows a common pattern in form elements, where a value attribute maps to a defaultValue property and where the value property is only initialized from the value attribute.

The code to desugar this is as follows. Bear with me, accomplishing this in (roughly) vanilla JS requires quite a bit of code:

const template = createATemplateWithBindingsSomehow();

class MyInput extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ["value"];
  #view = null;
  #value = new Signal();
  #defaultValue = new Signal();

  get value() { 
    let value = this.#value.get();

    if (value === void 0) {
      value = this.getAttribute("value");
      Signal.untrack(() => this.#value.set(value));
    }

    return value;
  }

  set value(value) { 
    this.#value.set(value);
  }

  get defaultValue() { 
    let value = this.#defaultValue.get();

    if (value === void 0) {
      value = this.getAttribute("value");
      Signal.untrack(() => this.#defaultValue.set(value));
    }

    return value;
  }

  set defaultValue(value) { 
    this.setAttribute("value", value); 
  }

  constructor() { 
    super();
    this.attachShadow({ mode: "open", delegatesFocus: true });
    
    if (this.hasOwnProperty("defaultValue")) {
      const value = element["defaultValue"];
      delete element["defaultValue"];
      Signal.untrack(() => this.#defaultValue.set(value));
    }

    if (this.hasOwnProperty("value")) {
      const value = element["value"];
      delete element["value"];
      Signal.untrack(() => this.#value.set(value));
    }
  }
	
  connectedCallback() {
    if (this.#view === null) {
      this.#view = createABindableViewSomehow(template);
      this.#view.appendTo(this.shadowRoot);
    }

    bindTheViewSomehow(this.#view);
  }

  disconnectedCallback() {
    unbindTheViewSomehow(this.#view);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case "value":
        this.#defaultValue.set(newValue);
        break;
    }
  }
}

export { MyInput };

Note

Again, you can see just how much boilerplate code there is, even for a simple scenario like this. Consider that the desugared code even leaves details out, particularly around how template updates work. This is why nearly everyone who writes Web Components today either ends up using a library or creating a library. A declarative format can eliminate all of this and enable creating Web Components easily without 3rd party code.

Continuing to set aside templating, let's look at what the <prop> element is doing for us:

  • The property is backed by a Signal field, which will be used to store the property's value and enable reactivity.
  • The property getter will initialize itself from the value attribute. When it is initialized, the change (which is really not a change), will not be tracked by the Signal system.
  • The property setter will directly update the underlying signal.
  • When the custom element upgrades, the constructor will check whether a property value was set prior to upgrade. Because this would shadow the prototype getter/setter, the own property will be deleted, and the underlying signal field will be updated with the value. Because this isn't a real change, just an initialization, the signal set will be untracked.

A <prop> init attribute has three potential values, each indicating a different way that the property can be initialized. They are as follows:

  • field (default) - This will initialize the property with a simple field initializer. The field's value is taken from the content between the start and end tags of the prop element, much the same as how <attr> works.
  • attr - This will initialize the property from an attribute value. The attribute to initialize from is taken from the content between the start and end tags.
  • context - This will initialize the property using the W3C Context Protocol. The context protocol enables the injection of arbitrary services and state into the component, without a hard dependency between the component and the service. The context to request will derived by creating a Symbol from the value taken from the content between the start and end tags.

To further clarify how context initialization works, consider this component:

<element export id="LoginWidget">
  <prop name="auth" init="context">AuthService</prop>

  <view>
    ...
  </view>
</element>

Which would desugar to something like this:

#auth = null;

get auth() { 
  if (this.#auth === null) {
    let result;
    const context = Symbol.for("AuthService");
    element.dispatchEvent( 
      new ContextEvent(context, found => result = found)
    );
    this.#auth = result;
  }

  return this.#auth;
}

Important

It may turn out that both properties and context will be important in enabling more advanced components to be cached by the browser. They both enable complex dependencies to be externalized and decoupled from the component implementation. For example, if component caching ends up being dependent on a component having no non-HTML import dependencies, then context will be essential to separating the cache-able component from the non-cache-able JavaScript code.

A summary of the <prop> element is as follows:

  • Use the <prop> element to declare a reactive property.
  • Use the name attribute to provide the name for the property.
  • Use the init attribute to indicate how the property should be initialized.
    • The valid values are field, attr, and context.
  • Use the type attribute to specify the property type. Known types include string, boolean, and number.
    • The default is type string.
    • Any other value is ignored. See "Open Questions" below.
    • Context properties are considered "unknown".
  • The content provided between the <prop> element's start and end tags will provide details related to initialization.

Here are a few examples of different <prop> configurations:

<!--
  A reactive property named "answer" that is
  of type number. Its field is initialized with
  the number 42. 
-->
<prop name="answer" type="number">42</prop>

<!--
  A reactive property named "value" that is
  of type string. It is initialized from
  the "value" attribute.
-->
<prop name="value" init="attr">value</attr>

<!--
  A property named "auth" that is initialized
  by using the W3C Context Protocol. A symbol created
  from the value "AuthService" is used to request
  the context.
-->
<prop name="auth" init="context">AuthService</prop>
Designer's Note

Similar to attributes, this would be a nice feature to back-port to the imperative JS API. For example, an observedProperties static property could be added, taking strings and configuration objects:

class MyElement extends HTMLElement {
  static observedProperties = [
    "aReactiveStringProperty",
    { name: "answer", type: Number, value: 42 },
    { name: "value", init: "attr", value: "value" },
    { name: "auth", init: "context", value: "AuthService" }
  ];
}

Open Questions

  • [Capabilities] Should we support any other property types besides string, boolean, and number? Should this be extensible so that component authors can provide their own? How would this work?

DCE States

Continuing on from attributes and properties, custom elements can have states. We can declare these as well, and provide expressions to indicate whether an element is in a particular state or not. Then, both internal and external styles can be driven by the state:

<element id="NavLink">
  <attr name="to" prop></attr>
  <state name="current">{{this.to === location.path}}</state>

  <view>
    <a part="link">
      <slot></slot>
    </a>
  </view>
</element>

The declarative state code desugars to something like this during view binding:

const internals = this.attachInternals();

new Effect(() => {
  if (this.to === location.path) {
    internals.states.add("current");
  } else {
    internals.states.delete("current");
  }
});

Note

More information on expressions and effects below.

DCE Events

In the same way that we've been able to declare attributes and properties, events could also be declared. Here's an example of what that might look like:

<element export id="AuthWidget">
  <event type="login-succeeded">User</event>
</element>

This would desugar to the following code:

class AuthWidget extends HTMLElement {
  loginSucceeded(detail) { 
    this.dispatchEvent(new CustomEvent("login-succeeded", { detail }));
  }
}

export { AuthWidget };

The <event> element simply creates a method on the component, configured with the correct code to dispatch the custom event. Here's a summary of what you can do with the <event> element:

  • The type attribute declares the type of event that will be dispatched.
  • The bubbles attribute indicates whether the event will bubble.
  • The cancelable attribute indicates whether the event can be canceled.
  • The composed attribute indicates whether the event is composed.
  • If the <event> element has content, this indicates that the custom event should pass the detail along, and what its type is.
    • The detail type is currently ignored, but likely useful for tooling.
  • A method is generated by pascal casing the event type, so that dispatching the event is simplified. Use the name attribute to explicitly name the method.
Designer's Note I freely admit that declarative event helpers like this are less valuable than declarative attributes and properties. I'm not entirely sure if they should be included yet. That said, they do have a few advantages:
  • Tools, code editors, and documentation generators can easily understand what events a component might dispatch.
  • Boilerplate is still eliminated for a highly common scenario.
  • With helper methods generated, this enables simple template event handlers to dispatch events without having to resort to script. (See "Templating" below.)

Reactivity

// TODO

Templating

// TODO

DCE/DSD Resumability

Presumably, with DCE in place, there will be no real use for DSD, at least not in combination with a DCE. Why repeatedly server render the DSD for every element instance when you can render the DCE once for all instances? Until or unless some compelling use case can be brought forward, this proposal recommends that no additional feature work needs to be done in this area.

DCE Scripts

The declarative model isn't intended to enable every component scenario without JS. So it supports adding JavaScript to extend the declarative element with additional behavior not possible otherwise. Here's an example:

<element export id="AuthWidget">
  <prop name="auth" init="context">AuthService</prop>
  <event type="login-succeeded" name="onLoginSuccess">User</event>

  <view>
    <form @submit="{{this.tryLogin()}}">
      <input type="text" id="username">
      <input type="password" id="password">

      <button type="submit">Log In</button>
    </form>
  </view>

  <script>
    export default class extends HTMLElement {
      async tryLogin() {
        const result = await this.auth.login(
          this.refs.username.value,
          this.refs.password.value
        );

        if (result.isSuccess) {
          this.onLoginSuccess(result.user);
        }
      }
    }
  </script>
</element>

And here's the desugared approximation (leaving out already discussed details):

class $element extends HTMLElement {
  // props, attrs, events, template, binding etc. from DCE elided
}

class AuthWidget extends $element {
  async tryLogin() {
    const result = await this.auth.login(
      this.refs.username.value,
      this.refs.password.value
    );

    if (result.isSuccess) {
      this.onLoginSuccess(result.user);
    }
  }
}

export { AuthWidget };

To add custom code to an element, a <script> tag is added with a single default export custom element. That element is then made to inherit from the DCE constructed by the platform.

Designer's Note I played around with a lot of script options before arriving here. Exporting specific lifecycle methods only were a problem, because there was no way to declare other members, such as fields or helper methods. Exporting a class "body" without the declarative header would require a new JS parsing mode. Exporting a function that could alter the DCE turned out to be a clunky programming model without the ability to use private fields. Ultimately, actually exporting a default class was the option that didn't require changing the JS language or giving up features.

Design-time Metadata

With the advent of DCEs, there is a large potential for tooling innovation. Tools will likely need to associate their own tool-specific metadata with elements. This proposal has two recommendations:

  1. Tooling vendors must add their metadata under a vendor-specific namespace.
    • e.g. ms:, figma:, adobe:, etc.
  2. The d: namespace should be reserved for community/standards metadata in the future.

The d: is tentatively chosen to represent:

  • Design
  • Develop
  • Documentation
  • Debug

All of these areas are places where metadata has been important in the past, both on the web and native platforms.

Designer's Note

The idea for using d: was taken from Xaml, which used this exact practice to encode design-time metadata in its UI language. As I worked on my compiler, I realized that there were at least the four Ds listed above, and so it seemed appropriate to adopt this. Examples of some of the metadata that the compiler supports are:

  • d:debug - Generates additional debug code in an element.
  • d:desc - Used to add a description to <element>, <prop>, <attr>, or <event>, enabling better documentation generation.
  • d:name - Provides a friendly name.

This proposal isn't recommending standardization of any of this at this time, but just a reservation of the namespace so that these scenarios can be explore more fully in the future.

Incremental Rollout Plan

The following is a high-level plan that tries to imagine how the full proposal could be shipped in phases. Each phase is designed to deliver value to stakeholders.

Phase 1 - HTML Modules

We should start with basic HTML Modules support. Being able to simply import a <template> from a module would be valuable on its own. The basic modules capability is a pre-requisite to almost everything else as well.

Features

  • HTML Module <template> export
  • HTML Module <style> export
  • Import HTML Module exports into:
    • The document
      • Support async and defer
    • Other HTML Modules
  • HTML Modules integration with ES Modules
    • via with { type: "html" }
  • Caching
    • Cache modules across page loads

Phase 2 - Basic DCE

With HTML Modules in place and supporting <template> and <style>, we can then move on to add the new <element> resource type. We should keep this to a minimal but useful DCE feature set for the first phase.

Features

  • Declare an <element>, including host attributes
  • Support <element> in document and HTML Modules
  • Declare <style> within an <element>.
  • Declare a basic <view>, including Shadow DOM attributes
    • Only support static HTML with slots, no bindings
    • Though not implemented, the templating syntax should be "reserved"
  • Integrate DCEs with the HTML Module caching system

Though limited, when combined with other declarative features, such as invokers, this feature set becomes quite useful. Any place where a repeatable, slotted set of HTML with encapsulated styles could be used, a DCE will now fit the bill. At this point, one might also expect DCEs to be used in place of DSD for a number of scenarios.

Phase 3 - DCE Scripts

Before moving on to the rest of the declarative features, next step should be to enable scripting in DCEs, accompanied by the templating refs functionality. This will make it possible for creators to adopt the declarative technology without having to wait on all the declarative features. Polyfills/Compilers will be able to integrate easier by allowing people to write the full declarative syntax today, transpiling that to a combination of declarative HTML and script.

Features

  • Enable <element> to contain a <script> that augments the behavior of the declarative element.
  • Capture any elements with id attributes in the <view> and make them available on the elements via a refs property.

Phase 4 - Basic Data Driven Templates

At this point, hopefully enough bike-shedding on templating syntax has wrapped up that there will be consensus on basic features. Depending on what details are worked out and the status of the TC39 Signals proposal, there may be more or less that can be done.

If Signals are in place, then the reactivity system can be based on that. If not, then a temporary HTML-specific, element-only reactivity system can be put in place that supports just the minimal templating features of this phase.

Features

  • Support <attr>, <prop>, and <state>
    • Add equivalent imperative APIs
      • This can be done at any point before or after this phase.
  • Support basic bindings
    • Binding to:
      • text content
      • attributes
      • boolean attributes
      • properties
      • events
      • token lists (classList, part)
      • style
    • Basic single property path bindings; no expressions
      • e.g. this.foo supported, but not this.foo < this.bar or this.foo.bar
  • Core DOM sink protection for bindings
  • No conditional or list directives

Phase 5 - Full Templating

The final phase involves increasing the expressivity of templating so that it matches most libraries/frameworks today. If Signals were not part of the previous phase rollout, then the temporary element-only reactivity system will need to be switched over to Signals at this stage, in order to support the broader set of templating features.

Features

  • Expressions in bindings
    • e.g. this.foo < this.bar
  • Complex property paths
    • e.g. this.foo.bar.baz
  • Signal integration
  • Binding options
    • e.g. event capture, one-time bindings
  • Conditional Rendering Directives
  • List Rendering Directives
  • Array Observation
  • Content Composition
    • e.g. Content bindings that return a template or element.

FAQ

// TODO

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