DEV Community

Ahmed
Ahmed

Posted on • Edited on

Versioned web components and micro front-ends

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:

  1. The UI library is versioned.
  2. Different MFEs might depend on different versions of the library.
  3. 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;
Enter fullscreen mode Exit fullscreen mode

Create a custom registry class:

class ShimmedCustomElementsRegistry implements CustomElementRegistry {
  define(name: string, elementClass: CustomElementConstructor): void {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 => {}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Replace the global registry: Change the class constructor with the customized class

window.CustomElementRegistry = ShimmedCustomElementsRegistry;
Enter fullscreen mode Exit fullscreen mode

then change window.customElements to an instance of the newly created class

Object.defineProperty(window, 'customElements', {
  value: new CustomElementRegistry(),
  configurable: true,
  writable: true,
});
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

  1. Lazy load your apps.
  2. 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


Happy coding! 🤖

Top comments (2)

Collapse
 
mohamedwagih profile image
Mohamed Wagih

Thank you for your insightful article, @sm3rta. While my familiarity with MFEs and web components is limited, I found the content engaging and informative.

Collapse
 
mohamed_sabryabed_7c4642 profile image
Mohamed Sabry Abed

Wow this is eye opening on overcoming the limitations of web components versioning inside MFEs! Thank you @sm3rta for sharing this extensive guide!