Shadow DOM directly influences how event propagate through the DOM. It took me quite some time to fully appreciate this. Even if there is a lot of content on the web covering this topic, I haven’t found an article going into all the nuances shadow DOM adds to event propagation.
This article is my attempt to give a complete overview of how eventing works in the shadow DOM.
Shadow DOM and encapsulation
Shadow DOM is a browser built-in encapsulation mechanism. This mechanism offers a way for developers to author components that are safe to distribute and consume on third-party pages. The shadow DOM encapsulation works both ways. First, it prevents component internals to be introspectable from outside the shadow DOM. But it also prevents shadow DOM internals from bleeding into the document.
One of the features Shadow DOM is most known for is its style scoping enforcement. Page-level styles don’t get applied to elements rendered in a shadow tree. And styles injected in the shadow tree don’t get applied at the page level.
All the spec adjustments and new features related to eventing that have been introduced along with shadow DOM revolve around this idea of how it enables developers to author components complying with this new encapsulation mechanism. Let’s dive into it.
The ShadowRoot
constructor
The ShadowRoot
is a constructor that has been introduced with shadow DOM. It extends EventTarget
and therefore it is capable of dispatching and listening for events like other DOM nodes.
const div = document.createElement("div");
const shadowRoot = div.attachShadow({ mode: "open" });
shadowRoot.addEventListener("test", (evt) => {
console.log(">> Shadow root listener invoked", evt);
});
const evt = new Event("test");
shadowRoot.dispatchEvent(evt);
The shadow root is the root node for a shadow tree. It is possible to listen to any event originating from a shadow tree by adding the appropriate event listener directly on the shadow root.
Similar to how events work in the light DOM, events only bubble in the shadow DOM if the bubbles
option is set to true
: new Event('test', { bubbles: true })
. Events dispatched from within the shadow tree invoke the shadow root listeners if the event is marked as bubbles
.
Escaping the shadow trees using composed
By default, events don’t propagate outside shadow trees. This default behavior ensures that internal DOM events don’t inadvertently leak into the document.
In the example, dispatching a bubbling event from the span#d
propagates to p#c
and the shadow root, but it stops there. The event never reaches div#a
since the element lives outside the shadow tree.
For an event to traverse shadow DOM boundaries, it has to be configured as composed
: new Event('test', { bubbles: true, composed: true })
. Going back to the previous example, if composed
is enabled, you can see the event escaping the shadow tree and reaching div#a
.
When the composed
option is set to true
, the dispatched event not only traverses its shadow boundary but also any other parent boundary. In the following example, the bubbling and composed event dispatched from span#e
bubble to div#c
, div#a
, and their respective shadow roots.
This might be counterintuitive, but a composed event always propagates outside the shadow boundary regardless of whether it is bubbling or not. Give it a shot and set bubbles
to false in the previous example to see how the event propagates.
As you can see, when the dispatched event is composed only, the event propagates from one host element to another, div#c
and div#a
, without propagating through the intermediary nodes. When thinking about DOM event propagation, bubbles
indicates if the event propagates through the parent hierarchy while composed
indicates if the event should propagate through the shadow DOM hierarchy. A bubbling and composed event propagates through all the nodes from the dispatched one up to the document root.
Now that you better understand how events propagate in the shadow DOM, it is important to call out that you should think carefully about how your events are configured, especially if you are building some complex applications. While it might be tempting to make them all composed and bubbling, it should not be your go-to events configuration. Events are part of the public API exposed by a web component. Not all events are equal, and only certain events are worth being exposed outside the component shadow tree.
What about standard events?
Most of the standard UI events bubble and are composed with a few exceptions. There are a few exceptions like mouseenter
and mouseleave
that aren’t bubbling and composed. Finally, the slotchange
event stands out as the only bubbling and non-composed event.
Complete event list
- Bubbling and non-composed events:
slotchange
- Non-bubbling and non-composed events:
mouseenter
,mouseleave
,pointerenter
,pointerleave
- Bubbling and composed events:
focusin
,focusout
,auxclick
,click
,dblclick
,mousedown
,mousemove
,mouseout
,mouseover
,mouseup
,wheel
,input
,keydown
,keyup
,keypress
,touchstart
,touchend
,touchmove
,pointerover
,pointerdown
,pointermove
,pointerup
,pointerout
Event.prototype.target
and event retargeting
The Event.prototype.target
property references the object from which the event was dispatched. When an event is dispatched from a DOM node, its target
is set to the node from which the event originates.
To preserve shadow DOM encapsulation and avoid leaking component internals, the target
is updated to the host element as events cross shadow boundaries. This process is called event retargeting. Let’s take the same nested shadow tree example that we used before to illustrate this aspect:
When the composed event propagates from div#c
shadow root to the host element, the event target
is set to div#c
. In the same way, once the event propagates through div#a
shadow boundary, the event target is set to div#a
.
One interesting side-effect of event retargeting is that once the event is done propagating, the event target
is always set to the outermost host element. In the case where you are doing debouncing, the event target
should be cached, otherwise the debounced method loses track of the event target.
Slotted content
Shadow DOM enables slotting content into a component using the <slot>
tag. When an event bubbles from a slotted node, the event propagates into the shadow tree first before propagating to the host element. This is true whether the event is composed
or not.
In the example above, p#b
is slotted into the div#a
shadow tree. When a bubbling event is dispatched from p#b
, instead of propagating directly to div#a
, it first propagates through all the elements in the shadow tree. This means that this event can be intercepted by slot#d
, div#c
, or the shadow root before reaching div#a
.
Event.prototype.composedPath
The Event.prototype.composedPath
method returns an array with all the nodes, in order, through which the event propagates.
When a bubbling and composed event is dispatched from span#d
the composed path contains span#d
, p#c
, shadow root, and div#a
. Something important to note about the composed path is that it not only includes the node with listeners that are invoked by this event but all the nodes in the path. In the previous example, when changing bubbles
to false
and leaving composed
to true
, the composed path remains identical even though the event directly propagates from span#d
to div#a
.
Careful readers might notice that the composed path remains identical as the event propagate, while the target is retargeted. The composed path offers an escape hatch to the shadow DOM encapsulation model as it gives you access to component internals. For example, as the event reaches div#a
, its target is set to div#a
. However, it is always possible to get a handle on the node which originally dispatched the event by looking up the first entry in the composed path.
Something that hasn’t been discussed in this article is the shadow tree mode
. Until now all the shadow trees presented in this article were open
shadow trees. Open shadow enforces a loose encapsulation as it is possible to get access to its internals via Element.prototype.shadowRoot
and Event.prototype.composedPath
.
Strict encapsulation can be enforced by setting the shadow root to closed
. In this mode, Element.prototype.shadowRoot
always returns null
regardless of whether a shadow tree is attached to the element or not. The composed path also omits nodes from closed shadow trees.
The example above illustrates this behavior with nested shadow trees. When dispatched from span#e
the composed path contains all the nodes in its path from span#e
to div#a
. But as it propagates to div#c
and div#a
, the composed path drops all the node from the closed shadow tree the event originates from.
Here is an even more contrived example with a closed shadow tree and slotted content.
When dispatched from p#b
the event composed path only includes p#b
and div#a
. When the event enters into the closed shadow tree and reaches slot#d
its composed path is updated to include the shadow tree node. The composed path is set again to its original value when it escapes the shadow tree and reaches div#a
.
Closing words
By now you should have a better idea of how eventing and shadow DOM work together. Here are a couple of takeaways:
- Events never cross shadow DOM boundaries except when
composed
is set totrue
. - When the
bubbles
option is set totrue
events propagate through the parent-child hierarchy. And when thecomposed
option is set totrue
events propagate through the shadow tree hierarchy. Event.prototype.target
is set to the host element when traversing a shadow tree boundary.Event.prototype.composedPath
returns all the nodes the event propagates through except when the shadow rootmode
is set toclosed
.
If you are still uncertain how it works in a specific scenario not covered in this article you can always edit any of the event visualizations in Codepen. For the most curious ones, the interactive examples are indeed web components built using lit and Rough.js. The source code can be found here.
Thanks to Nolan Lawson for feedback on the draft of this blog post.