An element inside a shadow root dispatches new CustomEvent('changed', { bubbles: true }), but a listener on document never fires. Which flag is missing, and what else changes once you set it?
answer
- two independent gates, not one
- the second one also defaults off
- boundary crossing, not upward travel
- target is rewritten on the way out
- native events disagree with each other
basics
~20 sThe composed flag is missing. It defaults to false, so a bubbling event stops at the shadow root. Adding composed: true lets it continue into the host's tree, where listeners see event.target retargeted to the host element.
solid answer
~50 s`bubbles` and `composed` are two independent gates. `bubbles: true` gets the event up through the shadow tree to the shadow root; `composed: true` is what allows it to cross the shadow boundary and keep propagating through the host and its ancestors. Both default to `false`, so a hand-built event needs both to reach `document` from inside a shadow root. Once it crosses, retargeting applies: outside listeners see `event.target` as the *host* element, not the inner node, because the shadow tree's internals are not exposed to the outer tree. That is usually what you want — the host is your component's public identity — but it means an outside listener cannot identify which inner node emitted, so anything the consumer needs must be in `detail`. Native events differ per event: `click` is composed, but `change` on a form control is not, which is exactly why a form control inside shadow DOM appears to swallow it.
code
javascript · 18 linesclass MyToggle extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<button type="button">go</button>';
root.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('my-toggle:changed', {
detail: { on: true },
bubbles: true,
composed: true
}));
});
}
}
customElements.define('my-toggle', MyToggle);
document.addEventListener('my-toggle:changed', (e) => {
console.log(e.target.tagName, e.detail.on); // MY-TOGGLE true
});go deeper
Remember that shadow DOM adds a second flag: an event needs both bubbles: true and composed: true to be heard outside the component.
Explain the split — bubbles governs upward travel, composed governs crossing the boundary — and that retargeting makes event.target the host for outside listeners.
Diagnose the real-world case: know that change is not composed while input is, and design the component to re-emit its own documented event from the host rather than depending on which native events happen to escape.
Treat the flag pair plus the detail shape as the component's published event contract, and set library-wide conventions for what is internal versus broadcast so consumers never reach into a shadow tree to compensate.
## Two flags, two gates A shadow root is a boundary in the node tree. Propagation across it is governed by a flag separate from bubbling: - `bubbles` decides whether the event travels upward at all, within whatever tree it is in. - `composed` decides whether it may leave a shadow tree and continue in the tree that contains the host. Both default to `false` on a constructed event. The failure in the question is the common one: the author sets `bubbles: true`, watches the event travel fine *inside* the component, and concludes propagation works — then finds nothing outside hears it, because the event ran out of road at the shadow root. ```js class MyToggle extends HTMLElement { connectedCallback() { const root = this.attachShadow({ mode: 'open' }); root.innerHTML = '<button>go</button>'; root.querySelector('button').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('changed', { detail: { on: true }, bubbles: true, composed: true })); }); } } ``` Note where the dispatch happens: on `this`, the host element, rather than on the inner button. Dispatching from the host is the cleaner pattern — the event originates at your component's public surface, so `composed` is not even required for it to reach ancestors. Dispatching from an inner node and relying on `composed: true` to escape works too, but pushes the retargeting question onto every consumer. ## Retargeting When a composed event crosses out of a shadow tree, the DOM rewrites `event.target` so listeners outside see the nearest ancestor that is visible to them — the host element. This is not obfuscation for its own sake: the whole promise of shadow encapsulation is that consumers do not depend on your internal markup. If outside listeners could read the inner `<button>` off `event.target`, every consumer would couple to your implementation, and you could not rename a node without breaking them. The practical rule that follows: **everything an outside listener needs must be in `detail`**. Do not expect consumers to dig into `event.target`; give them `detail: { value, id }` and treat that shape as your public contract. ## Native events are not uniform The per-event choice is made by whichever specification defines the event, and it is genuinely uneven — this is why a component wrapping a form control behaves surprisingly: - Composed: `click`, `focus`/`blur`, `input`, most pointer and keyboard events. - Not composed: `change`, `select`, and the `slotchange` event, among others. So an `<input>` inside a shadow root fires `input` events the page can hear but `change` events it cannot. The standard remedy is exactly the pattern above: listen for the native event internally and re-emit your own composed `CustomEvent` from the host, giving the component an explicit event API rather than leaking whichever native events happen to escape. ## Deciding the flags for a component Think of the pair as a public declaration: - **`bubbles: true, composed: true`** — a broadcast notification any ancestor may act on. The default choice for a component's public events. - **`bubbles: true, composed: false`** — deliberately internal: parts of your shadow tree talking to each other without the page seeing it. Useful for coordination between a component and its own subparts. - **`bubbles: false`** — a target-local signal; consumers must listen on the emitting node directly. Because the flags are frozen at construction, they belong in the component's documentation next to the event name and the `detail` shape. A consumer cannot opt in later. ## Closed shadow roots `attachShadow({ mode: 'closed' })` hides the root from `element.shadowRoot`, but it does not change propagation: a composed event still escapes a closed root, and retargeting still applies. Closed mode restricts *querying* the tree, not event flow, so do not treat it as an event-privacy mechanism.
- After a composed event crosses the boundary, what does an outside listener see as event.target?The host element, not the inner node that dispatched. Retargeting rewrites the target to the nearest node visible in the listener's tree so consumers cannot couple to your internal markup. The consequence is that any information the outside needs — which control changed, what value — has to travel in `detail`.
- Why does an <input> inside a shadow root seem to swallow its change event?Because `change` is specified as non-composed, so it stops at the shadow root, while `input` is composed and escapes. The fix is not to hunt for a flag on the native event — you cannot set one — but to listen for it internally and re-emit your own composed CustomEvent from the host, which also gives the component a documented event API.
- Does a closed shadow root stop composed events from escaping?No. `mode: 'closed'` only hides the root from `element.shadowRoot` and similar lookups; propagation and retargeting are unchanged, so a composed event still reaches the outside. Closed mode is about restricting who can query your tree, not about event privacy.
- Is it better to dispatch from an inner node with composed: true, or from the host element?Dispatch from the host. The event then originates at your component's public surface, ancestors receive it with plain bubbling, `event.target` is already the host with no retargeting subtlety, and consumers depend only on the documented name and `detail`. Escaping from an inner node works but exposes an implementation detail to reason about.
saying these in an interview costs you the question
- Thinks bubbles: true alone crosses a shadow boundary
- Expects event.target outside to be the inner element
- Assumes all native events are composed
- Treats a closed shadow root as blocking event escape
- Tries to set composed on a native event after the fact