Microfrontends and reusable Web Components are state-of-the-art concepts in Web Development. Combining both in complex, real-world scenarios can lead to nasty conflicts. This article explores how to run components in multiple versions without conflicts.
Microfrontend Environments (MFE)
In a MFE different product teams work on separate features of a larger application. One team might be working on the search feature, while another team works on the product detail page. Ultimately, all features will be integrated together in the final application.
These features range from being very independent to being closely coupled to other features on the page. Generally speaking, teams try to work as independently as possible, meaning also that they can choose which package dependencies or even frameworks they use - and which versions thereof.
Custom Elements
Web Components are a popular way of sharing and reusing components across applications and JavaScript frameworks today. Custom Elements lie at the heart of Web Components. They can be registered like this:
customElements.define('my-component', MyComponent);
You're now ready to use <my-component>
in the DOM. There can only be one Custom Element for a given tagName.
The Problem
Let's imagine the following situation: The MFE features should reuse certain components, more specifically they should reuse the Web Components provided by the Design System (DS). The DS is being actively developed and exists in different versions.
As each feature is independent, different teams might use different versions of the Design System. Separate features are developed in isolation and work fine with their specific version of the DS. Once multiple features are integrated in a larger application we'll have multiple versions of the DS running. And this causes naming conflicts because each Custom Element can only be registered once:
Feature-A uses
<my-component>
in version1.2.3
and Feature-B uses<my-component>
in version2.0.0
💥💥💥
Oops! Now what? How do we address this problem? Is there a technical solution? Or maybe a strategic solution?
Forcing feature teams to use the same DS version
One way to address this issue is to let the "shell application" provide one version of the DS. All integrated features would no longer bring their own DS version, but make use of the provided one. We no longer have multiple DS versions running.
While this might work in smaller environments, it's unrealistic for many complex environments. All DS upgrades would now need to be coordinated and take place at exactly the same time. In our case dictating the version is not an option.
The Design System
The problem is common when reusing Custom Elements in a complex MFE. It's not specifically created by Custom Elements but it's one that can be addressed by making small adjustments in the right places of the Custom Elements.
Our hypothetical Design System called "Things" has been built with Stencil - a fantastic tool for building component libraries. All components are using Shadow DOM. Some components are quite independent like <th-icon>
. Others are somewhat interconnected like <th-tabs>
and <th-tab>
. Let's check out the tabs component and its usage:
<th-tabs>
<th-tab active>First</th-tab>
<th-tab>Second</th-tab>
<th-tab>Third</th-tab>
</th-tabs>
You can find the full code of the components in their initial state here.
A Stencil solution
The first thing we'll do is enable the transformTagName
flag in our stencil.config.ts
:
export const config: Config = {
// ...
extras: {
tagNameTransform: true,
},
// ...
};
This allows us to register Custom Elements with a custom prefix or suffix.
import { defineCustomElements } from 'things/loader';
// registers custom elements with tagName suffix
defineCustomElements(window, {
transformTagName: (tagName) => `${tagName}-v1`,
});
Great! Feature teams can now register their own custom instances of the components. This prevents naming conflicts with other components and each feature time can work a lot more independently. Alternatively, the "shell application" could provide version-specific instances of the DS.
<!-- using v1 version of the tabs component -->
<th-tabs-v1>...</th-tabs-v1>
<!-- using v2 version of the tabs component -->
<th-tabs-v2>...</th-tabs-v2>
Let's imagine having 2 versions available. Feature teams can now pick from the provided options without having to provide their own custom versions.
We're not done, yet
Looking at <th-tabs-v1>
we can see that the icon component is no longer rendered. And the click handler even throws an error! So what's going on here?
Wherever a component references other components we'll potentially run into problems because the referenced components might not exist.
-
<th-tab-v1>
tries to render<th-icon>
internally, but<th-icon>
does not exist. -
<th-tab-v1>
tries to apply styles to theth-icon
selector which no longer selects anything - on click,
<th-tab-v1>
calls a function of<th-tabs>
, but<th-tabs>
does not exist -
<th-tabs-v1>
provides a methodsetActiveTab
which no longer finds any<th-tab>
child element
For every reference to another custom tagName we need to consider that the tagName might have been transformed using transformTagName
. As transformTagName
executes at runtime our component also need to figure out the correctly transformed tagNames during runtime. It would be great if Stencil provided a transformTagName
function that we could execute at runtime. Unfortunately, that's not the case. Instead, we can implement a (slightly ugly) solution ourselves.
transformTagName at runtime
export const transformTagName = (tagNameToBeTransformed: string, knownUntransformedTagName: string, knownUntransformedTagNameElementReference: HTMLElement): string => {
const actualCurrentTag = knownUntransformedTagNameElementReference.tagName.toLowerCase();
const [prefix, suffix] = actualCurrentTag.split(knownUntransformedTagName);
return prefix + tagNameToBeTransformed + suffix;
};
This function is not pretty. It requires 3 parameters to return a transformed tagName:
-
tagNameToBeTransformed
: tagName that we want to transform, i.e.th-tabs
-
knownUntransformedTagName
: untransformed tagName of another component, i.e.th-tab
-
knownUntransformedTagNameElementReference:
reference to element with that untransformed tagName, i.ethis.el
Usage example:
// file: tab.tsx
transformTagName('th-tabs', 'th-tab', this.el); // 'th-tabs-v1'
Note that
this.el
is a reference to the host element of the Custom Element created by the Element Decorator.
Fixing our components
Using our transformTagName
function we're now able to figure out which tagName transformation needs to be considered during runtime.
TypeScript call expressions
A Custom Element tagName may be referenced in querySelector(tagName)
, closest(tagName)
, createElement(tagName)
or other functions. Before we call these, we need to find out the transformed tagName.
// file: tab.tsx
// before
this.tabsEl = this.el.closest('th-tabs');
// after
const ThTabs = transformTagName('th-tabs', 'th-tab', this.el);
this.tabsEl = this.el.closest(ThTabs);
JSX element rendering
// file: tab.tsx
// before
public render() {
return <th-icon />;
}
// after
public render() {
const ThIcon = transformTagName('th-icon', 'th-tab', this.el); // 'th-tabs-v1'
return <ThIcon class="icon" />;
}
Please note the .icon
class, which will be required for the next step.
CSS selectors
// file: tab.css
// before
th-icon { /* styles */ }
// after
.icon { /* styles */ }
Wrapping it up
And we're done!
With a few small changes we've adjusted the codebase to support running multiple versions of the same Custom Elements. This is a huge step for complex Microfrontend Environments. It gives feature teams more freedom in choosing the versions they want to use and releasing when they want to release. It avoids couplings of features or feature teams. It also reduces coordination and communication efforts.
Find the code of the referenced example project in this Github repo. The second commit shows all required adjustments to support tagName transformations.
Performance considerations
Loading and running multiple versions of the same components at the same time will come with a performance cost. The amount of simultaneously running versions should be managed and minimal.
Top comments (2)
I'm wonder that if HTML support
scopeDocument.define('foo-bar', ...)
will be more elegant, something likeshadow css
for CSS andESM
for JS.Scoped Custom Element Registries would be the proper way of dealing with redefining already registered tags. Some people are working on this, but this still seems to be far away from being production-ready.
github.com/justinfagnani/webcompon...