Lazy Loading Custom Elements via an Auto-Loader

Custom Elements are a natural fit for lazy loading: the browser already knows how to upgrade an element once its definition shows up, so the only real problem is deciding when to fetch that definition. An auto-loader — itself a custom element, which makes it configurable straight from HTML — can watch the DOM and pull in implementations on demand.

Assume the module is loaded up front, ideally with async. A <ce-autoloader> placed in the document <body> treats that body as its root and immediately begins discovery across its children; nesting the element inside some other container limits discovery to that subtree, and there is nothing stopping you from running several instances over different subtrees. Worth noting: a simpler approach will often suffice, so treat the machinery below as a deliberate technique rather than a default.

Discovery starts by scanning the root and all of its descendants:

discover(scope) {
  let candidates = [scope, ...scope.querySelectorAll("*")];
  for(let el of candidates) {
    let tag = el.localName;
    if(tag.includes("-") && !customElements.get(tag)) {
      this.load(tag);
    }
  }
}

The * selector catches every descendant along with the root itself. A hyphen in the tag name identifies a custom element; if that element has not yet been upgraded, the auto-loader tries to load its definition. Since querying the DOM this broadly can be expensive, the scan should be deferred to keep the main thread free:

connectedCallback() {
  let scope = this.parentNode;
  requestIdleCallback(() => {
    this.discover(scope);
  });
}

requestIdleCallback isn't universally available yet, so requestAnimationFrame serves as a fallback:

let defer = window.requestIdleCallback || requestAnimationFrame;

class AutoLoader extends HTMLElement {
  connectedCallback() {
    let scope = this.parentNode;
    defer(() => {
      this.discover(scope);
    });
  }
  // ...
}

Loading a definition means injecting a <script> element at runtime:

load(tag) {
  let el = document.createElement("script");
  let res = new Promise((resolve, reject) => {
    el.addEventListener("load", ev => {
      resolve(null);
    });
    el.addEventListener("error", ev => {
      reject(new Error("failed to locate custom-element definition"));
    });
  });
  el.src = this.elementURL(tag);
  document.head.appendChild(el);
  return res;
}

elementURL(tag) {
  return `${this.rootDir}/${tag}.js`;
}

The src URL comes from a hard-coded convention inside elementURL: a single directory holds all definitions, so <my-widget> maps to /components/my-widget.js. That convention is good enough here, and keeping it in its own method leaves room for project-specific subclassing later.

class FancyLoader extends AutoLoader {
  elementURL(tag) {
    // fancy logic
  }
}

Because the URL is built from this.rootDir, the loader needs a matching getter:

get rootDir() {
  let uri = this.getAttribute("root-dir");
  if(!uri) {
    throw new Error("cannot auto-load custom elements: missing `root-dir`");
  }
  if(uri.endsWith("/")) { // remove trailing slash
    return uri.substring(0, uri.length - 1);
  }
  return uri;
}

observedAttributes would not simplify matters, and changing root-dir at runtime looks like a requirement that never materializes. Configuration is mandatory, then, and looks like <ce-autoloader root-dir="/components">.

Handling Elements Added Later

As written, the loader works exactly once — for elements present when it initializes. Elements added afterwards need a MutationObserver:

connectedCallback() {
  let scope = this.parentNode;
  defer(() => {
    this.discover(scope);
  });
  let observer = this._observer = new MutationObserver(mutations => {
    for(let { addedNodes } of mutations) {
      for(let node of addedNodes) {
        defer(() => {
          this.discover(node);
        });
      }
    }
  });
  observer.observe(scope, { subtree: true, childList: true });
}

disconnectedCallback() {
  this._observer.disconnect();
}

Each newly inserted element triggers the observer, which restarts discovery within the relevant subtree. In a sense this duplicates what the browser's own upgrade machinery does. A finish we want to make explicit: monitoring below is predicated purely on these two mechanisms.

With the observer wired up the auto-loader is complete. Possible follow-up work covers race conditions and further optimization, but for most scenarios the current shape should hold.