Make Your StencilJS Web Components Faster by Using Shadow DOM
By Dan Bellinski, 84.51° Lead Software Engineer
An application team using a design system should feel secure that the system’s components have been tested for performance, browser support, and accessibility. So when one of our applications was reporting slower than usual page loads on a template that was using our dropdown menu we knew something was amiss.
The dropdown menu was used in a table row to group actions. A few dozen instances of the dropdown menu caused the page load to slow to a crawl — rendering most of the UI unresponsive. Perplexed, we investigated our StencilJS web components, spent hours debugging, and eventually found the root cause: not using Shadow DOM on our StencilJS components was a big mistake.
Since then we’ve drastically improved the performance of our component and the page. In this article we’re going to share why Shadow DOM is important for StencilJS web component performance and other benefits we’ve learned along the way.
Performance Improvement 1: Rendering Slots
To understand the issue we need to first understand how StencilJS renders components, specifically components with slots. StencilJS lets you choose to render your components in 2 ways: with Shadow DOM disabled or enabled and by default is it disabled. Some of the choice depends on which browsers you need to support and if StencilJS’s Shadow DOM polyfills are “good enough” for your use case — e.g. IE11 doesn’t support CSS Custom Properties (variables) but there is a polyfill that provides some functionality.
If browser support is a question for Shadow DOM, why would you want to use it? Shadow DOM offers both DOM and style encapsulation, preventing anything outside of your component from interfering with it. This is really helpful to preserve the intended functionality of your components if you don’t know where or how they’ll be used.
When we first implemented StencilJS we thought the choice to use Shadow DOM was just going to impact how styles were rendered on our components and made our decision based on that. However, we’ve since learned that StencilJS will actually render your component mark-up in a completely different way when using slots with Shadow DOM disabled versus slots with Shadow DOM enabled — and that difference can have large performance implications.
The <slot> element is actually part of the Shadow DOM specification, but StencilJS will still allow you to use <slot> without Shadow DOM. Following are 2 examples of how StencilJS will render content passed into a slot with Shadow DOM disabled versus enabled.
Take a simple “mds-card” component implementation:
And the following user provided usage of the component:
Here is how StencilJS renders the <slot> contents with Shadow DOM disabled:
1) First, the user provided mark-up is added to the DOM:
2) Next, the component is expanded in the DOM and the slotted content is moved in the DOM to the proper place for the final result:
3) The browser can now render the full picture of the component using the above DOM representation.
Note here that the slotted content was actually moved in the DOM and replaced the <slot> element — the final DOM that the browser renders no longer has a <slot> element in it.
Alternatively, here is how StencilJS renders the <slot> contents with Shadow DOM enabled:
1) First, the user provided mark-up is added to the DOM:
2) Next, the component is expanded in the DOM. Notice that the <span> is not moved and stays where it is. The <span> is in what is called the “light DOM” — it isn’t rendered in it’s current state.
3) Lastly, the browser has to do some work. During the browser’s render step, the browser actually flattens the DOM to get it into the proper structure — but this change is only made for rendering! The DOM itself is not changed and remains as seen above.
The key difference between the two ways of rendering is how the DOM itself is rendered. Changing the DOM is a very expensive operation — which is why frameworks like React and Angular have implemented their own techniques to avoid updating the DOM as much as possible (Virtual DOM and Incremental DOM respectively). With StencilJS rendering a <slot> with Shadow DOM disabled, it is actually moving slotted content around in the DOM — and this is costly.
Evaluating Performance
Our dropdown menu leverages a <slot> to allow the user to pass their own menu items. We took this poorly performing dropdown menu component and ran some performance tests against it. We rendered 200 instances of the dropdown menu with 3 elements slotted into each dropdown menu. We performed the render of the 200 dropdown menus on the click of a button to have control over the results. This is what we found:
With Shadow DOM disabled, rendering all 200 dropdown menus took:
5000ms
With Shadow DOM enabled, rendering all 200 dropdown menus took:
1600ms
The component renders 3x faster when using Shadow DOM! This discovery was a clear sign we needed to switch our StencilJS components to use Shadow DOM.
We are now wrapping up the conversion of all 30 of our design system components over to use Shadow DOM. Through this process, we’ve learned a few more reasons that this was the right switch to make: “conditional slots” and “user driven slot changes”.
Performance Improvement 2: Conditional Slotting
With Shadow DOM disabled, if a user provides slotted content to a component that doesn’t have a slot to put it in, the slotted content still renders on the page (at the top of the component). This makes it difficult or near impossible to conditionally render the user’s provided content which means we always have to render it and only hide it with CSS.
For example, here is our Tag component, which doesn’t have a <slot>, with content slotted into it and Shadow DOM disabled:
With Shadow DOM enabled, if a user provides slotted content to a component that doesn’t have a <slot> to put it in, the slotted content is not rendered. This is because the browser will go to flatten the DOM at the time of render and see no place to put the content so it will stay in the “light DOM” which isn’t rendered. This opens up the door for conditionally rendering the user’s provided content which gives us further opportunities to tune the performance of our components.
For example, here is our Tag component (which doesn’t have a slot) with content slotted into it and Shadow DOM enabled:
We used this concept to remove our dropdown-menu’s “content” <slot> from the DOM when the dropdown menu wasn’t open. After making this change, we re-ran it through our performance test done above.
With Shadow DOM enabled + using a conditional slot, rendering all 200 dropdown menus took:
1200ms
We went from 5000ms to 1200ms to render 200 dropdown menus, now a 4x improvement! Things are starting to look better for this component.
Performance Improvement 3: User Driven Slot Changes
When we had Shadow DOM disabled on our components, we had a tough discovery — our Angular users were slotting content into our components using some Angular constructs like *ngIf and *ngFor and pointing them at asynchronous variables (pulled from NgRx state). The asynchronous behavior caused some of the slotted content to not be provided to the component at the first render of our component, but just shortly following the first render. Unfortunately this slotted content did not appear on the page at all until the next time a render was forced on that component.
What we learned is that StencilJS is not listening for slotted content changes, so we had to use MutationObserver to do our own listening. The short of the implementation is that the MutationObserver would look for node changes in the component and if they were inside of a <slot>, we’d force a re-render of the component. This was a bit ugly and caused some components to render twice on the initial load when they really didn’t need to, resulting in extra time to load.
With Shadow DOM enabled, we actually get this listening for free. The top level slotted content is observed and when it changes, the component re-renders without our intervention. We can even utilize the onSlotchange callback to perform any required updates to our component when the top level slotted content changes, e.g.:
<slot
name=”popover-trigger”
onSlotchange={() => this.triggerSlotChanged()}>
</slot>
Greener Grasses
We covered 3 improvements to our component’s performance that were realized by enabling Shadow DOM on our StencilJS component:
- Performance of Rendering Slots
- Conditional Slotting
- User Driven Slot Changes
We’re excited about the benefits we’ve gained and know it will lead to more performant user experiences in our applications leveraging our design system components. Aside from these benefits, there are plenty of articles on the web about the pros and cons of using Shadow DOM, mainly around style encapsulation. If you’re using StencilJS with Shadow DOM disabled on your components, we encourage you to take a look at what benefits you may gain from enabling it!