Describe the bug
When a Solid root is rendered inside the DOM tree of another Solid root (embedded widget / microfrontend / library that calls render() into a slot of the host app), delegated events from the inner root never reach the outer root's delegated handlers, even though the native event bubbles through the outer root's elements normally:
- The inner root's
onClick fires.
- A native
addEventListener("click", …) on the outer root's wrapper element fires — the event really does bubble there.
- The outer root's delegated
onClick on that same wrapper element never fires.
This contradicts the natural reading of documentation/solid-2.0/07-dom.md ("Root-owned event delegation"): "event.stopPropagation() inside a nested Solid root can prevent outer roots or host page code from observing the native event" — implying that without stopPropagation, outer roots do observe it. Here nothing calls stopPropagation, and the plain addEventListener on the same element proves the native event reaches it; only Solid's own delegated handler goes missing. The practical casualty is any delegated handler on a wrapper above an embedded root: click-outside-to-dismiss, analytics capture, modal scrims — all silently dead for clicks inside the embedded region, with no error and no signal (native listeners keep working, which makes it maddening to debug). It is also a regression from 1.x semantics (verified on 1.9.14: both the inner handler and the outer delegated handler fire), where document-level delegation walked the composed path across roots.
Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-lafdhps4?file=src%2FApp.tsx
import { createSignal, onCleanup } from 'solid-js';
import { render } from '@solidjs/web';
function mountCheckoutWidget(el: HTMLDivElement, log: (msg: string) => void) {
return render(
() => (
<button onClick={() => log('widget: Solid onClick')}>
Pay with widget
</button>
),
el
);
}
export default function App() {
const [log, setLog] = createSignal<string[]>([]);
let disposeWidget: undefined | (() => void);
const push = (msg: string) => setLog((l) => [...l, msg]);
onCleanup(() => disposeWidget?.());
return (
<main
onClick={() => push('host: Solid delegated onClick')}
ref={(el) => {
el.addEventListener('click', () => push('host: native click listener'));
}}
style={{ 'font-family': 'system-ui', padding: '16px' }}
>
<h1>Store checkout</h1>
<p>
The host app listens for clicks on the checkout area. A payment widget
mounts its own Solid root into the slot below.
</p>
<section>
<h2>Payment</h2>
<div
ref={(el) => {
disposeWidget = mountCheckoutWidget(el, push);
}}
style={{ border: '1px solid #999', padding: '12px' }}
/>
</section>
<pre>{log().join('\n') || 'Click Pay with widget'}</pre>
</main>
);
}
Steps to Reproduce the Bug or Issue
- Click "Pay with widget". Actual — the on-page log shows the widget handler and the plain
addEventListener on <main> fired (the event really bubbles through the host's elements), but the host's Solid delegated onClick on that same <main> never fires:
widget: Solid onClick
host: native click listener
- Nothing calls
stopPropagation anywhere.
Expected behavior
A delegated handler fires whenever the equivalent native listener on the same element would — every click logs all three lines:
widget: Solid onClick
host: native click listener
host: Solid delegated onClick
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Chrome
- Version: 2.0.0-beta.15 (verified at
next HEAD bad66625; 1.x comparison on solid-js 1.9.14)
Additional context
Root cause is eventHandler in node_modules/@dom-expressions/runtime/src/client.js (source repo: ryansolid/dom-expressions, packages/runtime/src/client.js). Both roots' container listeners receive the bubbling native event; the inner root's listener runs first and:
- marks the event consumed for every other root —
client.js:699:
function eventHandler(e, container, state) {
...
if (e[$$EVENT_OWNER]) return; // ← outer root's listener bails out here
...
e[$$EVENT_OWNER] = owner || true;
- stops its own handler walk at its root boundary —
client.js:731-735:
const walkUpTree = () => {
while (handleNode()) {
if (node === boundary || node.parentNode === boundary) break; // ← never leaves the inner root
node = node._$host || node.parentNode || node.host;
}
};
So the inner walk (correctly) never invokes outer handlers, but the $$EVENT_OWNER early-return then prevents the outer root's own listener from doing its walk.
The same root-scoping also affects hydration event replay: runHydrationEvents picks only the nearest registered root for a queued pre-hydration click, so even with live dispatch fixed, a click arriving during hydration would handle just the innermost root's segment — replay needs to relay through all matching roots innermost-first for consistent semantics.
Suggested fix direction (working patch with tests in hand, PR to follow): upgrade e[$$EVENT_OWNER] from a consumed-flag to "deepest boundary walked" — an ancestor container whose subtree contains the recorded boundary resumes the walk from that boundary element up to its own, instead of bailing. (Resuming at the boundary is load-bearing: walks fire handlers strictly inside a boundary, so the inner root's container element — which belongs to the outer root's JSX and may carry its own handlers — hasn't fired yet. A Set-of-containers marker is insufficient: without positional info an ancestor can't know where to resume without re-firing the inner segment.) stopPropagation() composes for free — it stops the native event before outer containers' listeners run. Zero cost for non-nested apps: the new path is unreachable unless a previous root already recorded a boundary. One deliberate behavior change to note: code using nested roots to sandbox clicks from outer handlers (i.e. relying on this bug) would see outer handlers fire again — stopPropagation is the documented isolation mechanism and continues to work.
Does this exist in Solid 1.x?
Regression from 1.x. Verified against solid-js 1.9.14 with the identical nested-render() shape: the inner root's handler, a native listener on the outer wrapper, and the outer root's delegated handler all fire exactly once (inner: 1, native: 1, outer: 1) — 1.x delegation is document-level and doesn't stop at root boundaries. In 2.0 the outer delegated handler is silently skipped.
Describe the bug
When a Solid root is rendered inside the DOM tree of another Solid root (embedded widget / microfrontend / library that calls
render()into a slot of the host app), delegated events from the inner root never reach the outer root's delegated handlers, even though the native event bubbles through the outer root's elements normally:onClickfires.addEventListener("click", …)on the outer root's wrapper element fires — the event really does bubble there.onClickon that same wrapper element never fires.This contradicts the natural reading of
documentation/solid-2.0/07-dom.md("Root-owned event delegation"): "event.stopPropagation()inside a nested Solid root can prevent outer roots or host page code from observing the native event" — implying that withoutstopPropagation, outer roots do observe it. Here nothing callsstopPropagation, and the plainaddEventListeneron the same element proves the native event reaches it; only Solid's own delegated handler goes missing. The practical casualty is any delegated handler on a wrapper above an embedded root: click-outside-to-dismiss, analytics capture, modal scrims — all silently dead for clicks inside the embedded region, with no error and no signal (native listeners keep working, which makes it maddening to debug). It is also a regression from 1.x semantics (verified on 1.9.14: both the inner handler and the outer delegated handler fire), where document-level delegation walked the composed path across roots.Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-lafdhps4?file=src%2FApp.tsx
Steps to Reproduce the Bug or Issue
addEventListeneron<main>fired (the event really bubbles through the host's elements), but the host's Solid delegatedonClickon that same<main>never fires:stopPropagationanywhere.Expected behavior
A delegated handler fires whenever the equivalent native listener on the same element would — every click logs all three lines:
Screenshots or Videos
No response
Platform
nextHEADbad66625; 1.x comparison on solid-js 1.9.14)Additional context
Root cause is
eventHandlerinnode_modules/@dom-expressions/runtime/src/client.js(source repo: ryansolid/dom-expressions,packages/runtime/src/client.js). Both roots' container listeners receive the bubbling native event; the inner root's listener runs first and:client.js:699:client.js:731-735:So the inner walk (correctly) never invokes outer handlers, but the
$$EVENT_OWNERearly-return then prevents the outer root's own listener from doing its walk.The same root-scoping also affects hydration event replay:
runHydrationEventspicks only the nearest registered root for a queued pre-hydration click, so even with live dispatch fixed, a click arriving during hydration would handle just the innermost root's segment — replay needs to relay through all matching roots innermost-first for consistent semantics.Suggested fix direction (working patch with tests in hand, PR to follow): upgrade
e[$$EVENT_OWNER]from a consumed-flag to "deepest boundary walked" — an ancestor container whose subtree contains the recorded boundary resumes the walk from that boundary element up to its own, instead of bailing. (Resuming at the boundary is load-bearing: walks fire handlers strictly inside a boundary, so the inner root's container element — which belongs to the outer root's JSX and may carry its own handlers — hasn't fired yet. ASet-of-containers marker is insufficient: without positional info an ancestor can't know where to resume without re-firing the inner segment.)stopPropagation()composes for free — it stops the native event before outer containers' listeners run. Zero cost for non-nested apps: the new path is unreachable unless a previous root already recorded a boundary. One deliberate behavior change to note: code using nested roots to sandbox clicks from outer handlers (i.e. relying on this bug) would see outer handlers fire again —stopPropagationis the documented isolation mechanism and continues to work.Does this exist in Solid 1.x?
Regression from 1.x. Verified against solid-js 1.9.14 with the identical nested-
render()shape: the inner root's handler, a native listener on the outer wrapper, and the outer root's delegated handler all fire exactly once (inner: 1, native: 1, outer: 1) — 1.x delegation is document-level and doesn't stop at root boundaries. In 2.0 the outer delegated handler is silently skipped.