1

So I have read several pieces that say if you want a custom event to traverse the shadow DOM boundary and cross into the light DOM you need to set the custom event's composed property to true. I noticed however that any events I dispatch from a web component's this. make it out of the shadowRoot component just fine, and ones that are dispatched from this.shadowRoot stay inside. So why do I need the "composed" property? Am I doing something wrong?

const internalEvent = new CustomEvent("internalEvent", {bubbles: true, cancelable: false})
const externalEvent = new CustomEvent("externalEvent", {bubbles: true, cancelable: false})

class MyComponent extends HTMLElement {
    constructor() {
        super()
        this.attachShadow({ mode: 'open' })
        this.shadowRoot.innerHTML = `
            <button id="internalButton">INTERNAL</button>
            <button id="externalButton">EXTERNAL</button>
        `
        this.internalButton = this.shadowRoot.getElementById("internalButton")
        this.externalButton = this.shadowRoot.getElementById("externalButton")
    }
    connectedCallback() {
        this.internalButton.addEventListener("click", ()=>{
            this.shadowRoot.dispatchEvent(internalEvent)
        })
        this.externalButton.addEventListener("click", ()=>{
            this.dispatchEvent(externalEvent)
        })
        this.shadowRoot.addEventListener("internalEvent", (event)=>{
            console.log("Internal event detected internally.")
        })
        this.shadowRoot.addEventListener("externalEvent", (event)=>{
            console.log("External event detected internally!")
        })
    }
}

document.addEventListener("internalEvent", ()=>console.log("Internal event detected externally!"))
document.addEventListener("externalEvent", ()=>console.log("External event detected externally."))
customElements.define('my-component', MyComponent)

edit: I'm just struggling to think of any reason where, to get a message to leave your component, you'd prefer to dispatch it within the shadowRoot and add a special property, rather than just dispatching it straight into the light DOM in the first place.

Roger Heathcote
  • 3,091
  • 1
  • 33
  • 39
  • "I noticed however that any events I dispatch from a web component's this. make it out of the shadowRoot just fine" quick double-check, did you test this on all browsers that support webcomponents? Also, a webcomponent's `this` shouldn't *be* in shadow dom, at least not it's *own* shadow dom. – Jared Smith Jan 20 '22 at 14:49
  • @JaredSmith Great point, I hadn't, but I just tried it on OSX versions of Chrome, FF and Safari and it works the same on all of them. As to your second point yes, that's kind of what I'm getting at, if I can just dispatch from `this` why would I not just do that instead of adding extra properties to my event? – Roger Heathcote Jan 20 '22 at 15:01

4 Answers4

2

'this' is the Custom Element/Web Component <my-component>,
'this' is NOT inside the elements shadowRoot.

So Events you dispatch from 'this', do not cross shadowDOM boundaries.

You only need composed: true when Events need to cross (aka "escape") shadowDOM –

<script>
  const EventName = "HelloFromComponent";
  customElements.define('my-component', class extends HTMLElement {
    constructor() {
      let attach = (btn, composed = false, el = this.shadowRoot.getElementById(btn)) =>
        el.onclick = () => {
          el.dispatchEvent(new CustomEvent(EventName, {
            bubbles: true,
            cancelable: false,
            composed: composed
          }))
        }
      super().attachShadow({mode: 'open'})
             .innerHTML = `<button id="one">One</button><button id="two">Two</button>`;
      attach("one", /* composed = */ false );
      attach("two", /* composed = */ true  );
    }
    listen(where) {
      where.addEventListener(EventName, (evt) => {
        console.log(where.nodeName, evt.type, evt.composed, );
      })
    }
    connectedCallback() {
      this.listen(this);
      this.listen(document);
    }
  });
</script>
<my-component></my-component>
Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
  • Thanks for your answer. I'm afraid I'm quite new to web components so my apologies for getting the language wrong. I understand you might want events from within a web component to escape their component. My question is, when that's the case, why not just dispatch regular events from `this` instead? As you point out `this` is not within the shadow DOM and it's available everywhere in your component, so if your event is destined for the outside world why bother dispatching from within the shadow DOM at all? – Roger Heathcote Jan 24 '22 at 10:23
  • Because you want to know where Events came from. You can yell from your own kitchen FIRE! Or you can go to the top of the building and yell FIRE! In other words, you want to know WHICH button was clicked, not "something inside shadowDOM" was clicked. Also see MDN docs: https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath – Danny '365CSI' Engelman Jan 24 '22 at 10:28
  • Thanks, that's really interesting. I hadn't really considered that case as I thought relying on a child's implementation details and/or state generally leads to unwanted coupling, but I suppose events aren't actually either of those things. I guess it will all become clearer once I've used web components to build something more substantial than toy examples. Thanks for your help. – Roger Heathcote Jan 24 '22 at 11:01
  • _**coupling**_ is the key word. Most of us always did tight coupling. Events are about loose coupling. Think Poker cards on a table, the table shouldn't know where the cards are, instead the cards understand their _state_ (I hate that hype word). A ``flop/river/turn`` **Event** makes the appropriate cards turn. – Danny '365CSI' Engelman Jan 24 '22 at 11:10
2

As others have mentioned, this is not in the shadow DOM of your component; it is the component that has this shadow DOM.

if I can just dispatch from this why would I not just do that instead of adding extra properties to my event?

It still wouldn't be able pass any possible surrounding shadowDOM boundaries (your web component may very well be a child or descendant of another web component that utilizes shadow DOM). This may be desirable or not, depending on where you want the event to be monitorable.

Also be aware that connectedCallback can be called multiple times, for example if an element is moved in the DOM; make sure to always remove any event listeners which you added in the connectedCallback in the disconnectedCallback, or even preferable, add the internal listeners in the constructor (which is guaranteed to only ever run once, and saves you the hassle of needing references to the listeners to be able to remove them).

connexo
  • 53,704
  • 14
  • 91
  • 128
  • "It still wouldn't be able pass any possible surrounding shadowDOM boundaries" I just tried it though, and it does. If I dispatch a normal event from the `this` of a web component slotted three levels deep inside other web components it triggers listeners in both parent components and outside of them, just the same as a `composed` event dispatched from shadowRoot. So still not sure what use the `composed` property actually has. Thanks very much for the tip re: putting listeners in the constructor btw, good to know! :) – Roger Heathcote Jan 24 '22 at 10:13
  • 1
    That is incomplete advice; if you do ``document.addEventListener`` in the ``constructor`` you will still have to remove them in the ``disconnectedCallback``. Only listeners attached "inside" the Element will be garbage collected. – Danny '365CSI' Engelman Jan 24 '22 at 11:18
  • @Danny'365CSI'Engelman That is correct and it's also the practice I've advised in other places here: https://stackoverflow.com/a/59970158/3744304 . I've added the word *internal*. – connexo Jan 24 '22 at 14:37
  • @RogerHeathcote Don't attach event listeners to elements outside of your component's scope (`this` and (elements in) the shadow root) in the constructor. Those should be attached in the `connectedCallback`, and properly cleaned up in the `disconnectedCallback`. Regarding the events, I was talking about custom events, not the standard events. `click` e.g. has `composed: true` by default. To try it just paste `window.onclick= console.log` in any Chrome console and click anywhere in the page. – connexo Jan 24 '22 at 14:42
  • Thanks for the clarifications @Danny'365CSI'Engelman. I was actually just in the middle of writing some tests to check what happens to listeners that are defined in different places when you move their elements around! – Roger Heathcote Jan 24 '22 at 15:07
  • Also thanks for the clarifications @connexo, I appreciate you taking the time to help me. – Roger Heathcote Jan 24 '22 at 15:08
  • 1
    Here is an (overengineered) JSFiddle playground for (composed) Events: https://jsfiddle.net/WebComponents/yc3r180m/ – Danny '365CSI' Engelman Jan 24 '22 at 16:59
  • @RogerHeathcote Also be aware that events passing shadow DOM boundaries are retargeted (`event.target`) as if they were coming from the host element that has this shadow root. – connexo Jan 24 '22 at 18:04
  • Thanks for that @Danny'365CSI'Engelman, I'll take a look at that soon. Out of interest I concluded my tests and it seems like it doesn't matter if connectedCallback adds a listener to an internal node again, as long as it points to the same handler function it does not get duplicated when you move the node so you don't need to unset it in disconnectedCallback. That said, if you use an anon function for your handler they do start to stack up, so I think connexo's suggestion to define internal listeners in the constructor and let the GC deal with clean up is a good one when it is practical :) – Roger Heathcote Jan 24 '22 at 19:39
  • @connexo that's good to know, thanks :) There's certainly always something new to learn in web dev eh! – Roger Heathcote Jan 24 '22 at 19:49
2

You're not doing anything wrong, your events will stop at a shadow boundary when it's reached because the composed flag is off, this includes the externalEvent as soon as my-component is placed in another element's shadow tree. The composed option allows the event to bubble up through these boundaries to the document.

Most custom elements, and the nodes in their shadowRoot, don't have awareness of the branch of the tree they're in, and when that respective position might or might not be in one or more shadowRoots along its ancestry towards the document root. In other words elements are composable both inside shadow trees and not. So if we want those events to pass shadow boundaries the composable option is used. The intent is to separate concerns and control the message passing through the various boundaries to fit the need.

When used with bubbling then the composed path provides the nodes the event passes through--I haven't experimented to see what happens in the different scenarios, like having this stop at the shadow boundary, etc. I have used the event path (an array from event.composedPath()) to have different logic in the handler based on the context (but I don't necessarily recommend this, it's easy to make it overly specific).

this dispatches out to the document no matter if it's from nodes in a shadowRoot, or the shadowRoot, or light dom elements that are within other custom elements with and without their own shadow trees

node.dispatchEvent(new CustomEvent('externalEvent', {detail:{stuff:'things'}, composed: true, bubbles: true}))

https://developer.mozilla.org/docs/Web/API/Event/composed

https://developer.mozilla.org/docs/Web/API/Event/composedPath

jimmont
  • 2,304
  • 1
  • 27
  • 29
0

It has just dawned on me that one reason (maybe The reason) would be if you wanted the event to be caught both inside AND outside of your component. Without the compose property you'd need to issue two separate events.

Roger Heathcote
  • 3,091
  • 1
  • 33
  • 39
  • *if you wanted the event to be caught both inside AND outside of your component* I'd say that is a rather rare case. You want events that pierce shadowDOM if that is part of how your component communicates to the outside world. – connexo Jan 21 '22 at 13:43