Introduction
Ever wondered if you can have two custom elements with the same name on one page? Would there even be a use case for something like this?
Well, imagine the following scenario:
You’re working with a micro-frontend (MFE) architecture where each MFE is an independent application relying on a shared UI library of web components. Sounds great, right? But here’s the catch:
- The UI library is versioned.
- Different MFEs might depend on different versions of the library.
- Upgrading all MFEs to the latest version simultaneously is a logistical nightmare.
Why is this a problem? Well, web components use the global namespace for their custom elements. This means you can’t define two versions of the same element (e.g., v1
and v2
of the same component) with the same name in the same document.
Scoped Registries: A Glimmer of Hope
Thankfully, some brilliant minds have already thought about this problem. Enter the concept of Scoped Custom Element Registries.
Back in 2020, a proposal was introduced (Scoped Custom Element Registries) to solve this exact issue. The idea? Allow multiple versions of a custom element to coexist in the same document by assigning each MFE its own custom element registry. Each registry would contain the element definitions specific to the version of the library that the MFE depends on.
This would allow multiple versions of a custom element to coexist in the same document, but the catch is that it hasn’t been implemented in browsers yet.
Polyfill
Since the proposal hasn’t made it into the web spec, we have a polyfill: @webcomponents/scoped-custom-element-registry.
But like any hero, this polyfill has its flaws. But to talk about limitations we need to understand first how it works under the hood
How the Polyfill Works
The polyfill essentially hijacks the browser’s window.customElements
registry and replaces it with a shimmed version.
Save the native registry: It stores the value of window.customElements
in a variable and sets it aside, since this is what the browser understands and uses to register and render custom elements.
const nativeRegistry = window.customElements;
Create a custom registry class:
class ShimmedCustomElementsRegistry implements CustomElementRegistry {
define(name: string, elementClass: CustomElementConstructor): void {
// ...
}
}
Introduce a "stand-in" class: When a tag is defined for the first time, a stand-in class is created to act as a router. It looks up the correct registry and renders the appropriate version of the element.
let standInClass = nativeGet.call(nativeRegistry, tagName);
if (!standInClass) {
standInClass = createStandInElement(tagName);
nativeDefine.call(nativeRegistry, tagName, standInClass);
}
Registry lookup: The lookup method in the stand-in element is to get the registry assigned to the nearest shadow root or the document (by calling getRootNode().registry
as an oversimplification)
const registryForNode = (node: Node): ShimmedCustomElementsRegistry | null => {}
Patch the stand-in element: After finding the correct registry, the stand-in element is patched to the appropriate definition.
if (definition) {
customize(instance, definition);
}
Replace the global registry: Change the class constructor with the customized class
window.CustomElementRegistry = ShimmedCustomElementsRegistry;
then change window.customElements
to an instance of the newly created class
Object.defineProperty(window, 'customElements', {
value: new CustomElementRegistry(),
configurable: true,
writable: true,
});
Issues with the Polyfill
1. Idempotency
Current latest version of the polyfill at the time of writing (0.1.0
) is not idempotent, meaning that if you load the polyfill multiple times which is kind of the expectation with MFEs, it will throw an error. There's an open issue for this
Fix: Wrap the polyfill in a function that checks if it’s already loaded.
export const scopedRegistryPolyfill = () => {
// Check if polyfill has already been applied
if (window.__SCOPED_CUSTOM_ELEMENTS_POLYFILL_APPLIED__) {
return;
}
// Mark as applied
window.__SCOPED_CUSTOM_ELEMENTS_POLYFILL_APPLIED__ = true;
// ... rest of the polyfill code
};
2. React Support
React’s virtual DOM lifecycle causes issues with the registry lookup. By the time the stand-in element constructs a node, it hasn’t connected to the DOM yet, so getRootNode()
fails to find the correct registry.
const instance = Reflect.construct(NativeHTMLElement, [], this.constructor);
Object.setPrototypeOf(instance, HTMLElement.prototype);
const registry = registryForNode(instance) /* this is always undefined */ || window.customElements; /* so it always falls back to global registry */
const definition = registry._getDefinition(tagName);
Fix: Move the registry lookup to the connectedCallback
method.
3. Nested Web Components
The polyfill doesn’t handle nested web components. For example, if a calendar component uses a button component internally, only the calendar component gets rendered.
Fix: Recursively customize child components in the connectedCallback
method.
const customizeChildren = (node: Element) => {
requestAnimationFrame(() => {
node.shadowRoot?.querySelectorAll('*').forEach((child) => {
if (child.tagName.includes('-') /* means it's a web component */) {
const definition = registry._getDefinition(
child.tagName.toLowerCase(),
);
if (definition) {
customize(child as HTMLElement, definition, true);
// Forward the registry to the shadowRoot so that lookup (registryForNode) works correctly
(child.shadowRoot as any).registry = registry;
customizeChildren(child);
}
}
});
});
};
customizeChildren(this);
Forked Polyfill and Proof of Concept
To address these issues, I’ve forked the polyfill and made some improvements. You can find the updated version and a working demo in this repo. The demo showcases two versions of the same web component coexisting on a single page using React and Vite.
Project Setup Instructions
For each MFE and the host you need to do the following
- Lazy load your apps.
- In the root file of each MFE:
- Run the polyfill.
- Create a new registry.
- Wrap your app in a shadow DOM and pass the registry to it.
- Export the registry object and registry all custom elements using the locally scoped registry.
Additional Resources
- Micro front-ends
- Web Components Documentation
- Custom Elements Specification
- Shadow DOM Specification
Happy coding! 🤖
Top comments (2)
Thank you for your insightful article, @sm3rta. While my familiarity with MFEs and web components is limited, I found the content engaging and informative.
Wow this is eye opening on overcoming the limitations of web components versioning inside MFEs! Thank you @sm3rta for sharing this extensive guide!