Skip to content

2.0.0-beta.15: outer root's delegated handlers never fire for events bubbling out of a nested render() root #2832

Description

@yumemi-thomas

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:

  1. The inner root's onClick fires.
  2. A native addEventListener("click", …) on the outer root's wrapper element fires — the event really does bubble there.
  3. 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

  1. 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
  1. 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:

  1. 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;
  1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions