DEV Community

Manuel Martín for Open Web Components

Posted on

Open-wc scoped-elements

"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)

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.

├─ 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 [see the code here].

Alt Text

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.

├─ 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 he/she will find the following error.

Alt Text

Looking at the web console we can read the following message

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.

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

but why is this happening?

ES modules are only executed once per URL so

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

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

@open-wc/scoped-elements

Recently Open-wc released 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.

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

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

// 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 [see the code here] works as expected using two different versions of the same component.

Alt Text

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 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 is going to work in the future.

Join the conversation

If you like this feature please feel free to join the conversation 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.

Top comments (0)