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.



