Skip to content

Instantly share code, notes, and snippets.

@btopro
Last active April 14, 2021 17:30
Show Gist options
  • Save btopro/7a10ee782dc2e2a864487e4371baf615 to your computer and use it in GitHub Desktop.
Save btopro/7a10ee782dc2e2a864487e4371baf615 to your computer and use it in GitHub Desktop.
Some thoughts on Scoped custom element registry proposals

Forward

I am not on the WHATWG / W3C, I'm just someone who implements and tracks low level JS/HTML APIs. The current direction and discussions around this topic are interesting yet seem to revolve around the concept of anonymous class instances of element definitions that ship with other elements.

The advantages of this methodology involve being able to ship completely containerized code that is not going to suffer issues with cool-stuff tag being registered globally when trying to download and implement whatever-tag which happens to ship with a competing reference to cool-stuff.

The scoped proposal can be found here: https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Scoped-Custom-Element-Registries.md

It's absolutely worth a read and a good direction moving forward. Below are some random thoughts and design / developer experience patterns and ideas I had based on other things I've written that use the concept of a registry. To my knowledge, the haxtheweb.org team shipping unbundled, self-hydrating web components, is the only project viewing the concept of a "registry" in this way. I am happy to be proven wrong but if that idea / word soup didn't mean much, here's a lengthy article about how and why we unbundle our assets.

ELI5

  • we compile files in place
  • we ship these files in es8 to evergreen
  • our tooling builds a registry json blob of all web components in our monorepo
  • we then allow a unified build.js entryway to do all the hard work of figuring out what to hydrate based on usage
  • this allows <simple-icon-button> downloaded once, used across thousands of properties and without compiling it per application using it

I think this gives us a unique window into how these things could be used and thus I decided to write up some thoughts on the subject. Always happy to chat, critique, or just say "wow, that's a weird idea" and keep on keeping on.

2c of ideas on the subject

The implementer of the registry of elements, let's say from a social media platform, might have their own mental model for ingesting and leveraging the web components of that provider. Twitter, wants to update their APIs. They don't want to have to constantly tell you to get the new twitter-card.js from npm or else it'll break. So, as everyone always has, they ship you a script tag, you use it to inject via a global selector an iframe in. The iframe is the god of all gods at scoping, and whalla you have something in an iframe.

The proposal for a scoped registry could work for this use-case for sure but basically assumes a blockbox comes across. The elements are scoped relative to themselves. In this instance this probably makes perfect sense. But what if we had a design library or something possibly leveraged across multiple places? Is it going to be possible to say scope element 1, but don't scope 2, but scope 3 because it's NOT in the shadowRoot of scoped element 1 but it IS in the application's lightdom so use the global scope? My concern here is that the scoping itself will be very difficult to discern from a a DX perspective.

There's a mention in the working document about the notion of overhead in the element being tied to the registry which I'm sure is valid, but without being able to imperitively scope an element like <out-button scope="project1"> I think it could be unmanagable to keep track of the mental model of why element A got it's definition from B.

an example of this concern:

<html>
  <script type="module">
    class whateverClass extends HTMLElement {
      constructor();
    ...
    }
    customElements.define('global-thing', whateverClass);

    // local scope to this element
    class AnotherTag extends HTMLElement {
      constructor();
    ...
    }
    const registry = new CustomElementRegistry();
    registry.define("another-tag", AnotherTag);
    // global scoping
    class AnotherTagAlso extends HTMLElement {
      constructor();
    ...
    }
    customElements.define('another-tag', AnotherTagAlso);
  </script>
<body>
  <div>
    <global-thing>
        <template shadowroot="open">
          <another-tag></another-tag>
        </template>
        <another-tag></another-tag>
     </global-thing>

In this mock up I'm thinking there's a gap currently around <template shadowroot= and having an associated scope, however assumuing there was a way to bridge that, you'd get the following HTML that loads two different class definitions for things that live almost in the same place! Without some kind of debugging tools at the browser vendor level I could see it being extremely confusing as to why one tag loaded w/ the definitions it did.

I like the direction of the current proposals and look forward to where they are heading.

Some alternative integration concepts

Below are some ideas for how things could be integrated in a complementary or enhanced way. I don't think these would be competing w/ the existing concepts and I hope maybe they can jog some ideas around the subject. At the very least it's fodder for my students to read through and consider.

Given our background in making and shipping unbundled registries, here's how I'd conceive of an element registry and how it could be aligned with other existing approaches in order to optimize performance and DX. I don't build spec level code and this is my first forray into even trying to approach these topics so take with a grain of saltm, however multiple groups have asked us about using scoped distributed element registries and so the following would provide a possible solution to that as well.

<html>
  <head>
    <!-- preconnect to open ssl handshake to different registries of webcomponents people can use -->
    <link rel="preconnect" crossorigin href="https://social.media.cdn.com" />
    <link rel="preconnect" crossorigin href="https://cdn.webcomponents.psu.edu" />
    <!-- preload to start eager parsing -->
    <link rel="preload" href="https://social.media.cdn.com/registry.json" as="fetch" crossorigin="anonymous"/>
    <link rel="preload" href="https://cdn.webcomponents.psu.edu/cdn/wc-registry.json" as="fetch" crossorigin="anonymous"/>

    <!-- 
      Proposed: registration added in via initial DOM parse to parallelize / establish
      registries to the HTML entities in the document ahead of time. This would reduce
      FOUC and awaiting registry definition loading when individual element / applications
      from different providers, teams, or parts of existing CDNs.
      DX - implementer side:
      Implementer of assets can redefine assumptions of the registry builder if needed.
      `title` would allow for the association of all definitions found on the other end
      to be associated automatically w/ the twtr registry
      This allows the providers of these assets to define things in a way that is agnostic
      of how the implementation takes place.
      DX - asset owner / publisher side:
      Owner can publish in a reusable manner for others while having a mental model of
      their own in place. Frees them to publish things under name spacing of `cool-button`
      and anything else they want while doing registrations against a global element registry
      just as before. Detection and evaluation within that registry would then be changed
      as proposed below.
    -->
    <link type="customelementregistry" title="twtr" href="https://social.media.cdn.com/registry.json"/>
    <link type="customelementregistry" href="https://cdn.webcomponents.psu.edu/cdn/wc-registry.json"/>
    <!-- 
      possible responses from these manifests
      https://cdn.webcomponents.psu.edu/cdn/wc-registry.json
      {
        "hax" : {
          "hax-body" : "@lrnwebcomponents/hax-body/hax-body.js",
          "hax-tray" : "@lrnwebcomponents/hax-body/lib/hax-tray.js",
        },
        "funny": {
          "meme-maker": "@lrnwebcomponents/meme-maker/meme-maker.js"
        }
      }
      
      https://social.media.cdn.com/registry.json
      {
        "twtr-card": "twtrels/tCard.js",
        "twtr-button": "twtrels/dist/btn.js"
      }

      The assumed registry object then becomes internally

      {
        "twtr": {
          "source": "https://social.media.cdn.com/registry.json",
          "elements": {
            "twtr-card": Class loaded from "twtrels/tCard.js",
            "twtr-button": Class loaded from "twtrels/dist/btn.js"
          }
        },
        "hax" : {
          "source": "https://social.media.cdn.com/registry.json",
          "elements": {
            "hax-body" : Class loaded from "@lrnwebcomponents/hax-body/hax-body.js",
            "hax-tray" : Class loaded from "@lrnwebcomponents/hax-body/lib/hax-tray.js"
          }
        },
        "funny": {
          "source": "https://social.media.cdn.com/registry.json",
          "elements": {
            "meme-maker": Class loaded from "@lrnwebcomponents/meme-maker/meme-maker.js"
          }
        }
      }

      An advantage of this is potentially having prims also accessible via this
      as a utility to developers.
      {
        "_primatives": {
          "source": "HTMLElement",
          "elements": {
            "h1": "HTMLHEADINGElement",
            "h2": "HTMLHEADINGElement",
            "img": "HTMLImageElement",
            ...
          }
        }
      }
      Definitions loading at run time could have a similar source to imply they came from the
      webpage run state itself.
      
      This would solve the following issues in the present implementation:
      - currently no simple way of obtaining a list of what tags are defined in the DOM
        custom or otherwise.
      - <webview> is only on certain environments, <portal>, <dialog> are not widely adopted.
      - The registry would help with future look ups as new native and custom registry defined tags
        are made available
      - elementRegistry.has("meme-maker") or elementRegistry.has("portal") returning false
        or an array of references would solve issues of future duplicate definition resolution as well
      - avoids issues of composition when meme-maker moves between different definition contexts
        as it gets appendChild'ed out of a shadowRoot w/ definition from registry 1 into a shadowRoot
        that is under the control of a different definition. Under this model the tag's association
        is to a global object

      other things:
      - this could be aligned with the importmaps spec to follow similar definition patterns as they
      are potentially complementary concepts.
    -->

Module cascade / extending the existing customElement registry

  // baseEl.js
  import { LitElement, html, css } from "lit-element/lit-element.js";
  export { html, css };
  export class OurBaseEl extends LitElement {

    createRenderRoot() {
      return this.attachShadow({ mode: "open", registry });
    }
    static get registryScope() {
      return "custom-namespace";
    }
  }


  // meme-maker.js - which will implement our element scoping
  import { OurBaseEl, html, css } from "./baseEl.js";
  export class MemeMaker extends OurBaseEl {
    constructor() {
      super();
    }
    render() {
      return html`<div>Something funny</div><slot></slot>`;
    }
    static get styles() {
      return [css`
        :host {
          display: block;
        }
      `];
    }
    static get tag() {
      return "meme-maker";
    }
  }
  // defined into the scope the base class provided
  const registry = new CustomElementRegistry();
  registry.define(MemeMaker.registryScope, MemeMaker.tag, MemeMaker);

  // hax-meme-maker.js - which will hijack element scoping + global
  import { html, css } from "./baseEl.js";
  import { MemeMaker } from "./meme-maker.js";

  export class HaxMemeMaker extends MemeMaker {
    // custom functionality of this element as example
    static get haxProperties() {
      reuturn {...};
    }
    
    static get tag() {
      return "hax-meme-maker";
    }
    static get registryScope() {
      return "hax";
    }
  }
  // defined into the scope local to this implementation, extended from an element in 1 registry, based off
  // a base class in no registry
  const registry = new CustomElementRegistry();
  registry.define(HaxMemeMaker.registryScope, HaxMemeMaker.tag, HaxMemeMaker);
  // global scope like it is currently
  customElements.define(HaxMemeMaker.tag, HaxMemeMaker);
  // but COULD allow for custom support for the custom element registry as available
  customElements.define(`${HaxMemeMaker.registryScope}:${HaxMemeMaker.tag}`, HaxMemeMaker);

This could be used in conjunction with a modified version of our deduping script (which helps prevent bricking on name space collisions currently)

// ponyfill hack sorta thing that would account for scope called in : format
const _customElementsDefine = window.customElements.define;
window.customElements.define = (name, cl, conf) => {
  const namePieces = name.split(":");
  let tagName,scope;
  if (namePieces.length === 2) {
    scope = namePieces[0];
    tagName = namePieces[1];
  }
  else {
    tagName = namePieces[0];
  }

  // look for a definition of the form namespace:tagname
  if (window.CustomElementRegistry) {
    if (scope) {
      const registry = new CustomElementRegistry();
      registry.define(scope, tagName, cl, conf);
    }
    else if (!customElements.get(tagName)) {
      _customElementsDefine.call(window.customElements, tagName, cl, conf);
    }
    else {
      console.warn(`${tagName} has been defined twice and may have unknown impacts`);
    }
  }
  // legacy browsers don't have CustomElementRegistry, just pull tag name if undefined
  else if (!customElements.get(tagName)) {
    _customElementsDefine.call(window.customElements, tagName, cl, conf);
  } else {
    console.warn(`${tagName} has been defined twice and may have unknown impacts`);
  }
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment