DEV Community

Cover image for Versioning Web Components
Florian Rappl
Florian Rappl

Posted on

Versioning Web Components

Photo by Stefan Cosma on Unsplash

Over the last years web components have matured quite a bit. Still, there are areas where web components are unfortunately not yet easy to fit in. Surprisingly, micro frontends are one of the areas that actually do not only benefit from web components, but will also face real issues if used directly.

In this article we go into one of these troublesome use cases for web components arising with micro frontends. We'll cover how a web component-based component library can be used by multiple micro frontends in independent versions. But first, let's have a look at the problem in depth.

Web Components and Versioning

Let's say you have two micro frontends - red and blue. Both want to use a component library. Let's just share it (green):

Two micro frontends using a single shared library

Since the component library is based on web components the shared part is actually trivial - they need to be shared. After all, to register a web component the following global call is made:

customElements.define('my-component', MyComponent);
Enter fullscreen mode Exit fullscreen mode

Both micro frontends can then access the my-component element. So far, this does not present any problem. But now, let's say the team behind the component library releases a new version.

While the red micro frontend actually wants to use this new version (purple), there is bug in a core component used by the blue micro frontend. So, at least for the time being, this team wants to remain on the old version.

Two micro frontends using different versions of the library

This is a new version, yes, but the team wanted to keep breaking changes minimal. As such, both libraries want to register a different component to the same element name. Unfortunately, this is not supported and will throw:

// called from v2
customElements.define('my-component', MyComponentV2);

// called from v1
customElements.define('my-component', MyComponentV1); //this will throw!
Enter fullscreen mode Exit fullscreen mode

Even if the library has been developed defensively, it would not yield the right result:

// called from v2
if (!customElements.get('my-component')) {
  customElements.define('my-component', MyComponentV2);
  // registered
}

// called from v1
if (!customElements.get('my-component')) {
  customElements.define('my-component', MyComponentV1);
  // never called, nothing registered
}
Enter fullscreen mode Exit fullscreen mode

Now, both teams would unknowingly use the same component - but the blue micro frontend explicitly wanted to use the old v1 version.

Consequently, there must be a way to properly version and roll out the component library to support this use case.

Challenges and Solutions

Alright, let's start with the most striking issue. What if we would just release with a certain version-dependent name?

customElements.define('my-component-v2', MyComponentV2);
Enter fullscreen mode Exit fullscreen mode

The granularity of this is debatable. Yes, the suffix could be used for API breaking changes, but what if there is some internal bug that was resolved in one version, but is included in another?

For instance, for a daily build we could define:

customElements.define('my-component-2022-09-01', MyComponent);
Enter fullscreen mode Exit fullscreen mode

Even though this might be as granular as possible, it would be pretty much unusable. Any update to the library would require changing all the references. Unless... we can make an alias. Can we?

// original definition - very granular
customElements.define('my-component-2022-09-01', MyComponent);
// alias definition
customElements.define('my-component', MyComponent);
Enter fullscreen mode Exit fullscreen mode

This has two problems:

  1. By giving the alias a predefined name we run into the same problem that we want to avoid
  2. A web component reference (the class behind it, i.e., MyComponent in the example above) can only be used once

The latter can be fixed by using an anonymous class extending the original class:

// original definition - very granular
customElements.define('my-component-2022-09-01', MyComponent);
// alias definition
customElements.define('my-component', class extends MyComponent {});
Enter fullscreen mode Exit fullscreen mode

Now we are left with the first issue. This one could be solved by making the alias registration configurable:

// original definition - very granular
customElements.define('my-component-2022-09-01', MyComponent);

export function registerComponent(name = 'my-component') {
  // alias definition
  customElements.define(name, class extends MyComponent {});
}
Enter fullscreen mode Exit fullscreen mode

This way, the default version of the component library will be auto-registered (e.g., using registerComponent() implicitly), while the blue micro frontend will use the custom registration, explicitly calling registerComponent with a new name for the component.

Explicitly register components

From the perspective of the blue micro frontend the code could look like:

import { registerComponent } from 'component-lib/custom';

registerComponent('other-component');
Enter fullscreen mode Exit fullscreen mode

Great! So we can define aliases, but does that fully help us?

It turns out there are a couple of issues with this approach:

  1. If we do not isolate the styling (e.g., instead of using shadow DOM we use innerHTML or similar) we have trouble identifying the element by a fixed tag name
  2. If within the component library we reference some component by its tag name then we have an issue

To solve the first problem we can either introduce class names, inline styling, or CSS-in-JS. The former two have the issue that their specifity is quite high, i.e., in order to override the styling (which presumably is wanted in such cases, otherwise using shadow DOM would be preferred I guess) the use of !important would be necessary.

Using a CSS-in-JS solution, however, makes sense for web components. They should anyway be styled by some mechanism (e.g., lit styles) that is close / defined within JS.

So we could just do the following:

function getSheet() {
  const element = document.createElement('style');
  document.head.prepend(element);
  return element.sheet;
}

function defineComponent(name, BaseClass) {
  // wrapper to always succeed here
  if (!customElements.get(name)) {
    const sheet = getSheet();
    customElements.define(name, class extends BaseClass {});
    sheet.insertRule(`${name} { ${BaseClass.styles || ''} }`, 0);
  }
}

// default definition
defineComponent('my-component-2022-09-01', MyComponent);

export function registerComponent(name = 'my-component') {
  // alias definition
  defineComponent(name, MyComponent);
}
Enter fullscreen mode Exit fullscreen mode

where we expect the given component class (e.g., MyComponent) to have a static styles property like this:

export class MyComponent extends HTMLElement {
  static styles = `
    color: green;
    font-weight: bold;
  `;
}
Enter fullscreen mode Exit fullscreen mode

This way, we can apply some default styling exactly to the registered tag name. Even better, if we register the element multiple times then each gets the default styling applied - independently of each other. Hence, overriding the styles of one tag does not impact the others.

That only leaves us with the reference problem. Here, we need to stay consistent. The following diagram illustrates this:

Resolving internal references consistently

Using a x-button-group component from both, the red and the blue, micro frontends is only possible if we follow the steps we derived so far. We require a dedicated name for the embedded version used in the blue micro frotend. But that is not everything: Inside the x-button-group we also need to reference whatever tags have been defined outside (in our case: the blue micro frontend). Well, this is a challenge.

One way out of this is to keep book of the context. Inside the component we always know our tag:

export class MyComponent extends HTMLElement {
  constructor() {
    super();
    // this will log the currently used name, e.g., `x-button-group` or `x-button-group-v1`
    console.log(this.localName); // "x-button-group"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can use this information if we perform a joint registration like so:

const contexts = [];
const components = {
  'x-button': Button,
  'x-button-group': ButtonGroup,
};

export function registerComponents(getName = (name) => name) {
  const tagNames = [];
  const aliases = {};

  Object.entries(components).forEach(([name, cls]) => {
    const tag = getName(name);
    aliases[name] = tag;
    tagNames.push(tag);
    defineComponent(tag, cls);
  });

  contexts.push({ tagNames, aliases });
}
Enter fullscreen mode Exit fullscreen mode

We get aliases and tags.

Alias bag and tag list

By using the localName from the current component instance to match the provided tags (we guarantee that it appears once and only once) we find the right context, giving us the aliases. So, for the ButtonGroup to find the correct name of a Button we can use:

export function findTagName(localName, defaultName) {
  // get the right context - we are only interested in its aliases
  const { aliases } = contexts.find(c => c.tagNames.includes(localName));
  // resolve the current tag name from the provided alias mapping
  return aliases[defaultName];
}
Enter fullscreen mode Exit fullscreen mode

This is pretty much all that's necessary to cover the most important use cases. Let's see this in action.

Demo Project

I've prepared a demo project on the basis of Piral. The running demo can be found at the Piral samples organization on GitHub. Running the demo locally does not look very spectacular.

The resulting demo app

In the end, we have two micro frontends contained in one application. One micro frontend is using the embedded / centrally provided version of a web component library (lib-v1), while the other one is using its own version (lib-v2).

The setup of the first micro frontend is:

import "./style";
import * as React from "react";

export function setup(app) {
  app.registerTile(
    () => (
      <div className="teaser">
        Welcome to Piral 1 using the shared version of the component lib!
        <lib-container>
          <lib-button-group></lib-button-group>
        </lib-container>
      </div>
    ),
    {
      initialColumns: 4,
      initialRows: 4,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, lib-container and lib-button-group are used. These are components from the web component library. While the lib-container uses a shadow DOM for styling and a <slot> for its children, the lib-button-group redefines its content by setting the innerHTML property. The latter has therefore to be styleable from the outside. Also, it directly references lib-button internally.

The setup of the second micro frontend is:

import "./style";
import { registerAll } from "lib-v2/register";
import * as React from "react";

registerAll((name) => `new-${name}`);

export function setup(app) {
  app.registerTile(
    () => (
      <div className="teaser">
        Welcome to Piral 2 using its own version of the component lib!
        <new-lib-container>
          <new-lib-button-group></new-lib-button-group>
        </new-lib-container>
      </div>
    ),
    {
      initialColumns: 4,
      initialRows: 4,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Pretty much the same, except that we use the lib-v2/register module instead of just importing lib-v2. Then, this micro frontend uses the imported registerAll function to actually alias all these components.

The result is that both library versions co-exist, and can even be styled independently, e.g.:

/* mf-one */
lib-button:nth-child(1) {
  color: blue;
}

/* mf-two */
new-lib-button:nth-child(2) {
  color: red;
}
Enter fullscreen mode Exit fullscreen mode

The demo also includes components written using Lit. For Lit a couple of things need to be respected:

  1. Components should not be registered right away (i.e., drop the decorator)
  2. The dynamically resolved tag names need to be included via unsafe constructs

The unsafe constructs can be done in Lit like this:

import { LitElement, html, css } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { getAlias } from "./aliases";
import { buttonTag } from "./Button";

export class ButtonGroup extends LitElement {
  render() {
    const button = getAlias(this.localName, buttonTag);
    const code = unsafeHTML(
      `<div><${button}>A</${button}><${button}>B</${button}><${button}>C</${button}></div>`
    );
    return html`${code}`;
  }
}

export const buttonGroupTag = "lib-button-group";
Enter fullscreen mode Exit fullscreen mode

There could be even more convenient wrappers on top of this, like

function renderFragment(localName, defaultTag, content) {
  const tag = getAlias(localName, defaultTag);
  const code = unsafeHtml(`<${tag}>${content}</${tag}>`);
  return html`${code}`;
}
Enter fullscreen mode Exit fullscreen mode

The result is a DOM as we want it:

DOM structure of the tiles

Conclusion

With the right utility functions and architectural decisions you can create a library consisting of web components that can be used in quite flexible ways. The key question is if you want or need to support that use case.

My personal preference is to always stay agile. Allowing different teams to make their own decisions what usage of your library fits best to their use case is always a good goal to have in mind.

Oldest comments (3)

Collapse
 
joehonton profile image
Joe Honton • Edited

Another possibility is to treat each new component as if it were in beta testing before allowing others to use it. So the first beta version that other teams are given a peek at might be called <beta1-fancy-button>. Any breaking changes that you want to share would be published as <beta2-fancy-button>. Eventually, everyone moves on to the next problem and forgets about fancy buttons, so you silently publish the canonical version as <fancy-button>. Later, the teams using the beta versions discover that things have settled down, so they grep their code base, remove the beta prefix, test for breakage, and are good to go.

Collapse
 
christianulbrich profile image
Christian Ulbrich

The canonical answer on how to run the same set of web components is scoped elements:

Scope element tag names avoiding naming collision and allowing to use different versions of the same web component in your code.

Still this requires, that your set of web components are not self-registering and thus forces you to modify the web components. This might not always be possible, if you are not in control of the web component library.

There are other non-invasive approaches available by modifying the CustomElement registry, that I outlined 3 years ago, by modifying CustomElement.define() to allow registering CustomElements with the same name over and over again, by having a host component that delegates to the actual CustomElement that will get a scoped name. This allows you do all kind of magic:

  • live updating of web components while the browser is running
  • different versions of the same component per micro-frontend.

If your web components are self-contained concerning the styling, this allows you to run different versions of the same application, without modifying the application code at all. This is very important, because that way you won't have to modify the complete application, if you want to migrate to another version.

While I only build a rough-rough prototype, this should work perfectly for micro-frontend composition. You only need to come up with some kind of wrapper for the micro-frontend, telling the custom element loader to determine the version from. This can be as simple as a <div version="2.2.1"><mf-one></mf-one></div>

For a small overview:

| technique  | impact on app source | web components need to be adjusted | self-registering components are supported | remarks |
| ---------- | -------------------- | -----------------------------------| ----------- | --- |
| Piral-specific aliasing, app rewrite | huge, because **every** template needs to be re-written | yes, if they _self-register_ | no | Most invasive technique, needs to modify source + web components, not standard, no upgrade path for older versions w/o rewrite, only works for Piral environments |
| Scoped Elements | small, because templates can stay the same for certain environments | yes, if they _self-register_ | no | Standard solution, that works across frameworks, relying currently on polyfill |
| custom CustomElementLoader | none | no | yes | DOM Mutation observer might have certain performance impacts, still a non-invasive solution |
Enter fullscreen mode Exit fullscreen mode

Of course all of the proposed techniques will only work, as long as your web components of a shared library do not rely on any global state, that they are syncing across the document, because this is still globally and shared. Let's see what's up in the making for ShadowRealms next week at TC39!

PS: Sorry, but somehow the markdown table is not working...

Collapse
 
florianrappl profile image
Florian Rappl

Not sure why you think this is Piral specific or somewhat related to Piral. I made the MF demo with Piral, but the technique and the article have nothing to do with Piral and the sharing / demo could have been done in the same way using whatever other thing you have in mind.