Running multiple versions of a Stencil design system without conflicts

Running multiple versions of a Stencil design system without conflicts

Andre Sander · February 15, 2022

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 an 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 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 version 1.2.3 and Feature-B uses <my-component> in version 2.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 the th-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 method setActiveTab 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 needs 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.e this.el

Usage example:

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.

// 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

// Before
public render() {
  return <th-icon />
}

// After
public render() {
  const ThIcon = transformTagName('th-icon', 'th-tab', this.el);
  return <ThIcon class="icon" />;
}

Please note the .icon class, which will be required for the next step.

CSS Selectors

// 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 them 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.


Andre Sander