More Resilient Web Components in Angular (or anywhere else) with MutationObserver

A real-world example of hardening your components against unknown contexts

Kyle Buchanan
PatternFly Elements
6 min readApr 29, 2019

--

Part of the beauty of web components is that they are designed to work with other JavaScript frameworks. Take a look at Custom Elements Everywhere. The support for web components in other frameworks is excellent across the board. That said, there are still some quirks that, as a web component developer, you’ll need to overcome and adjust for when building web components that will work everywhere. Angular has shown me that we need to make a few adjustments in our connectedCallback() if things are going to go smoothly for our web component consumers.

As good web component developers, we know that if we want to inspect the light DOM or read attributes on our component, we should do this work in the connectedCallback(). The v1 web component spec states

“In general, work should be deferred to connectedCallback as much as possible — especially work involving fetching resources or rendering.”

So, if we need to do some initial setup by querying the light DOM or reading attributes, it should be safe to do that in the connectedCallback(), right? Unfortunately, that doesn’t seem to be the case with Angular. Maybe there’s something that I’m missing or don’t understand about Angular. But, I’ve seen other developers struggling with the same thing. To recreate the issue to better understand what’s going on, let’s use a simple example web component that I know works and then we’ll use that same component in Angular application and see what’s actually happening.

Simple web component example

This is just a simple web component that creates a shadow root and reads an attribute and queries the light DOM for some text in the connectedCallback(). The simple HTML markup that we’ll be using in our examples is:

<my-web-component color="dark">
<h1>My Web Component</h1>
</my-web-component>

And the javascript for the component is:

Everything is working as we’d expect it to. If you check the console, you’ll see color attribute “dark” and h1 text “My Web Component”. Perfect! But what happens when we run the same code in an Angular app?

Angular example

We’ll take our code from the previous example and drop it into an Angular application. When the code runs, we get some unexpected results.

Check the console and you should see color attribute “null” and Error: Cannot read property ‘textContent’ of null. That’s not good 😕. I thought that according to the v1 web component spec we’d be safe to query the light DOM and read attributes in the connectedCallback(). As shown in the example, we’re not able to do either in Angular. So what’s going on?

Debugging the connectedCallback() issue in Angular

I set a breakpoint on the first console.log() in the connectedCallback().

I then took a look at the Elements tab in the developer tools to see what was going on with themy-web-component tag.

That’s odd. Where’s the color="dark" attribute? Where’s my <h1>My Web Component</h1> light DOM? After I hit play on the breakpoint, I took another look at the Elements tab in the developer tools.

Everything is magically back 🤔. What appears to be happening is that Angular is removing the contents of our component’s light DOM and all of its attributes. Angular removes the attributes and light DOM, runs it through some kind of process, and then puts it all back with additional Angular looking attributes like _ngcontent-sqm-c0 which is probably something Angular does to keep track of the app’s state and children. Clearly this isn’t what we expected to happen with our web component. If we had some initialization that we needed to happen in our connectedCallback(), it wouldn’t have run and we would be left with a broken component. Despite this issue with Angular, the way we fix this will help make us better web component developers.

A good problem

The problem we’re looking at in Angular highlights an issue that web component developers need to be aware of. If we need to run some initialization code in our connectedCallback() and that was the only place that the initialization code ran, what would happen if our component’s light DOM ever changed? How would our initialization code run again? It wouldn’t. Our component would initialize once and if the light DOM ever changed, we could be left with a broken component. What if we had a tabs component and we dynamically added a new tab or tab panel? How would our component know to run the initialization code again? That’s where a MutationObserver can help us out. Not only will the MutationObserver help us build a better component, it will also solve our Angular issue in the connectedCallback()from earlier.

Using a MutationObserver to run initialization code instead of connectedCallback()

Using a MutationObserver in our component will allow us to watch for changes in the light DOM and then react to those changes. Adding a MutationObserver to my-web-component is pretty easy. There are just a few things we need to set up correctly.

  1. Create an initialization method like _init to house our initialization code.
  2. Add the MutationObserver in the constructor and have the callback call our initialization method.
  3. In the connectedCallback(), check to see if you have children in the light DOM already. If you do, run your initialization code.
  4. Start observing using the MutationObserver in the connectedCallback().
  5. Remember to disconnect the MutationObserver in the disconnectedCallback().

We’re not getting rid of our connectedCallback(). We’re still using it and checking to see if there are children in our light DOM. If there are children, run our _init() method and then we let the MutationObserver observe any changes.

If you check the console, you should see the same two messages we had before. However, our web component is more resilient now due to the MutationObserver. Anytime our light DOM changes, we’ll be able to run our initialization code again and make sure our component is still running properly. Great! Now, let’s update our Angular app and see the result.

Updated Angular app with a MutationObserver in our component

I just copied and pasted the code from our previous example into our Angular app and made one change so TypeScript would stop yelling at me (see line 4 where I added _observer: MutationObserver;).

Now if you look at the console, you should see color attribute “dark” and h1 text “My Web Component”. Now we have exactly what we need for our web component to work in Angular.

Solving the Angular problem and building more resilient web components with MutationObservers

In the end, running into this issue in Angular and solving for it helps us make our web components more resilient in that they can respond to changes to the light DOM. Rob Dodson, a developer advocate at Google, made a comment on an Angular GitHub issue that helped guide me to this solution.

At the end of the day, a custom element just needs to be super resilient because you never know what context it will be used in.

While Rob is referring to observedAttributes and the attributeChangedCallback in the GitHub issue, the same applies for things changing in the light DOM. We need to make sure our initialization code in our components is not a one-time shot. It needs to be able to react to changes in the DOM. This is where the MutationObserver really comes in handy.

Resources

For more information on MutationObservers, MDN, as always, has some excellent documentation and examples.

--

--