Open-wc scoped-elements
Tags: javascript, webcomponents, microfrontends, webdev
Published: 2020-01-23 14:39:30

>"Good frontend development is hard. Scaling frontend development so that many teams can work simultaneously on a large and complex product is even harder." ([martinfowler.com - micro frontends](https://martinfowler.com/articles/micro-frontends.html))

Micro-frontends, as well as micro-services, are gaining popularity. Many organizations are adopting those architectures allowing multiple autonomous teams to work on the same applications without the limitations of large monoliths.

To have visual consistency across micro-frontends, one common approach is to have a shared library of reusable UI components, but basing this library on web components could be a problem in certain situations. We are going to create some dumb components to emulate it, analyze the problem, and see how to fix it.

## The context

Imagine we have the first version of a shared component library, containing two components:

- feature-a
- feature-b

In addition, two pages use those components contained in our shared library. Imagine that each page has been developed by autonomous teams.

Finally, we have the shell app that contains the pages. Once the app is built we will obtain the following `node_modules` tree.

```sh
├─ node_modules
│  ├─ feature-a@1.0.0
│  │  ├─ feature-a.js
│  │  └─ index.js
│  ├─ feature-b@1.0.0
│  │  ├─ feature-b.js
│  │  └─ index.js
│  ├─ page-a@1.0.0
│  │  ├─ page-a.js
│  │  └─ index.js
│  └─ page-b@1.0.0
│     ├─ page-b.js
│     └─ index.js
├─ demo-app.js
└─ index.html
```

So far so good. Everything is up and running and you can check the application [online](https://open-wc.org/scoped-elements/demo/before-nesting/) [[see the code here](https://github.com/open-wc/open-wc/tree/master/packages/scoped-elements/demo/before-nesting)].

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/gw1nr0sd8v9oxd4j6z8l.gif)

## The problem

Imagine now the requirement of release a breaking change on `feature-a` to fulfill new business requirements. A new major version of `feature-a` would be released.

The team in charge of page A has enough time and budget to update their page and implement the required changes using the latest release of `feature-a`, but unfortunately, the team in charge of page B has other business priorities before adapting their code to the new version.

As they are independent teams, each one releases their new page versions and the app is built obtaining the following `node_modules` tree.

```sh
├─ node_modules
│  ├─ feature-a@2.0.0
│  │  ├─ feature-a.js
│  │  └─ index.js
│  ├─ feature-b@1.0.0
│  │  ├─ feature-b.js
│  │  └─ index.js
│  ├─ page-a@1.1.0
│  │  ├─ page-a.js
│  │  └─ index.js
│  └─ page-b@1.1.0
│     ├─ mode_modules
│     │  └─ feature-a@1.0.0
│     │     ├─ feature-a.js
│     │     └─ index.js
│     ├─ page-b.js
│     └─ index.js
├─ demo-app.js
└─ index.html
```

As the user tries to [execute the application](https://open-wc.org/scoped-elements/demo/no-scope/) he/she will find the following error.

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/i1n05yx52mpyhopbyscf.png)

Looking at the web console we can read the following message

```sh
NotSupportedError: 'feature-a' has already been defined as a custom element
```

The problem here is that the custom element registry doesn't allow multiple versions of the same element to be registered and we are trying to register two versions of the `feature-a` component with the same name.

```js
customElements.define('feature-a', FeatureA);
```

but why is this happening?

ES modules are only executed once per URL so

```js
import 'feature-b/feature-b.js';
```

in both, `page-a/index.js` and `page-b/index.js`, resolves to `node_modules/feature-b/feature-b.js` so it's going to be executed only once. However, doing

```js
import 'feature-a/feature-a.js'
```

in `page-a/index.js` resolves to `node_modules/feature-a/feature-a.js`
while in `page-b/index.js` it resolves to `node_modules/page-b/node_modules/feature-a/feature-a.js` therefore these are separate URLs and `feature-a` definition will be executed both times.

If you want to dig deeper into how node resolution works you can read [this article](https://dev.to/open-wc/nested-dependencies-in-frontend-558c#how-node-resolution-works) which explains it very well.

## The solution

There are two possible solutions:

1. Synchronizing updates of shared dependencies across teams. e.g. make sure all teams always use the same version upon release. This can be a viable solution but it comes with high organizational overhead and is hard to scale. I would discard this option because I want to provide value to the user as soon as possible and this option requires extra work from the teams.

2. Temporarily (!) allow to ship similar source code (most breaking releases are not a total rewrite) and scope them via [@open-wc/scoped-elements](https://open-wc.org/scoped-elements).

### [@open-wc/scoped-elements](https://open-wc.org/scoped-elements)

Recently [Open-wc](https://open-wc.org) released [scoped-elements](https://open-wc.org/scoped-elements) as an experiment, allowing us to use different versions of the same web-component in a single document. Let's see how we can use it to fix our sample application.

First of all, we have to install `@open-wc/scoped-elements`.

```sh
npm i --save @open-wc/scoped-elements
```

Once installed, we have to modify our page's components to use it.

```js
// page-a/index.js
import { render/*, html */ } from 'lit-html'; // (1)
import { createScopedHtml } from '@open-wc/scoped-elements'; // (2)
// import 'feature-a/feature-a.js'; (3)
// import 'feature-b/feature-b.js'; (3)
import { FeatureA } from 'feature-a/index.js'; // (4)
import { FeatureB } from 'feature-b/index.js'; // (4)

const html = createScopedHtml({ // (5)
  'feature-a': FeatureA,
  'feature-b': FeatureB,
});

export class PageA extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    render(html`
      <style>:host { display: block; padding: 10px; border: 2px solid #ccc; }</style>
      <h3>I am page A</h3>
      <feature-a></feature-a>
      <feature-b></feature-b>
    `, this.shadowRoot);
  }
}
```

Let´s see what we did here:

1. Remove the `html` function from `lit-html` because we must use the `createScopedHtml` provided one instead.

2. Import the function `createScopedHtml` from `scoped-elements`.

3. Remove the imports that contain the self-definition of the components that we are going to use.

4. Import the component classes that we want to use inside our component. This is an important step because now `FeatureA` and `FeatureB` components are not self-defined anymore.

5. Use `createScopedHtml` to indicate how to use `FeatureA` and `FeatureB` components inside our component HTML. This function returns another `html` function that transforms a template literal into a new one replacing the tags used by the developer with the ones defined by the custom elements. Finally, the transformed template literal is going to be processed by `lit-html` returning a `TemplateResult`.

We can see that the [final result](https://open-wc.org/scoped-elements/demo/with-scope/) [[see the code here](https://github.com/open-wc/open-wc/tree/master/packages/scoped-elements/demo/with-scope)] works as expected using two different versions of the same component.

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/xp4rocenozqhpkzdfill.gif)

## Limitations

But it's not all fun and games. There are some limitations using `scoped-elements` that are important to understand:

1. Imported components should not be self-registering.

2. Every component that contains subcomponents must use `scoped-elements´.

3. Imported components need to be fully side effect free.

4. Currently, only `lit-html` rendering engine is supported.

5. You can not use tag selectors in CSS, but you could use an id, a class name or even a property instead.

6. You can not use tag names using javascript querySelectors, but you could use an id, a class name or even a property instead.

7. You can not use `document.createElement` to create a scoped element, but there is an open [issue](https://github.com/open-wc/open-wc/issues/1270) to discuss how to improve the API and support it.

8. Using `scoped-elements` may result in performance degradation of up to 8%.

As a good practice, loading of duplicate/similar source code (most breaking releases are not a total rewrite) should always be a temporary solution. However, temporary solutions tend to become more permanent, so be sure to focus on keeping the lifecycle of nested dependencies short.

In a nutshell, it is all about stopping components from self-registering and telling them how they should be used. The concept is similar to how [Scoped Custom Element Registries](https://gist.github.com/justinfagnani/d67d5a5175ec220e1f3768ec67a056bc) is going to work in the future.

## Join the conversation

If you like this feature please feel free to join the [conversation](https://github.com/open-wc/open-wc/issues/1262) for feedback, criticism, concerns, or questions.

## Disclaimer

`scoped-elements` is an experimental feature, so use it at your own risk and be sure to understand the previous limitations.