Skip to content

fix(web): move delegated event handlers off the Solid 1 $$<type> key - #3522

Merged
ryansolid merged 1 commit into
nextfrom
fix/delegated-event-key
Sep 17, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/delegated-event-key

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 17, 2026

Copy link
Copy Markdown
Member

Problem

Solid 1 delegates from document and fires any $$click / $$input / … it finds while walking up from the target. Solid 2 delegates from the render root but stamped the same $$<type> key on elements. So on any page with both:

  • a 1.x runtime (an older embedded widget, a devtools panel built on 1.x — both a 1.x custom element beside a 2.x app and TanStack devtools inside a 2.x app were reported on Discord) re-fires every delegated handler in the 2.x app, and
  • a 2.x root fires 1.x handlers rendered inside it, which 1.x's document listener then fires again.

1.x's eventHandler consults nothing on the event object besides cancelBubble (and its own hydration replay buffer while hydrating), so no "already handled" mark can stop it. Scoping our listener to the root only controls what our walker sees. The only lever that works against a runtime we can't change is a key it doesn't look for.

Change

Compiled output (@solidjs/babel-plugin, @solidjs/compiler) and the runtime (addEvent, eventHandler) now stamp _$$<type> / _$$<type>Data. Emit shape is unchanged — _el$._$$click = handler — so there is no per-element cost.

_$ is the existing Solid-owned expando family (_$host, _$owner, _$classes, _$styles, _$multiple). The extra $ gives event keys their own namespace so a user-configured custom delegated event (delegatedEvents: ["host"]) can never collide with one of those.

Wire contract

The key, the _$SOLID_EVENT_OWNER mark, and the walk rules are now documented at the top of client.ts as the delegated-event wire contract. It is read off the DOM and the event by whichever Solid copy is listening, so it is shared between every copy on a page — two bundles of the same major nested in each other, or a future major nested in this one — and is frozen going forward. Changing any of it means a new key prefix, not a new shape under the old one. (Prior art: Svelte hit exactly this within 5.x when a minor changed the shape of __clicksveltejs/svelte#17057.)

Tests

packages/web/test/delegated-event-cross-runtime.spec.tsx:

  • carries a faithful copy of the 1.x walker attached to document beside a 2.x root — light DOM, open shadow root (the reported custom-element setup), and 1.x content nested inside a 2.x root — asserting single dispatch in each direction. All four fail on the old key (verified by flipping the runtime and compiler back and rebuilding).
  • loads client.ts twice as separate module instances (?copy=2) and nests a root from one inside the other: single dispatch, inner→outer ordering, stopPropagation across instances, outer handlers above the inner root still fire. This pins the same-major-twice case and doubles as the freeze test for the contract.

Full suites pass: compiler (Rust + 5785 fixture cases), Babel plugin, web under both native and Babel JSX (client/server/hydrate), test-types, h, html, element.

Migration

Anything reading el.$$click directly must switch to el._$$click.

Workarounds no longer needed

Closed shadow roots, delegateEvents: false, and root.addEventListener(type, e => e.stopPropagation()) all worked around this from the app side; after this change none are required for 1.x interop.

@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6d2bdeb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/diagnostics Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fix/delegated-event-key (6d2bdeb) with next (7624320)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Solid 1 delegates from `document` and fires any `$$click`/`$$input`/… it
finds while walking up from the target. A 1.x runtime on the same page —
an embedded widget, a devtools panel built on 1.x — therefore ran every
delegated handler in a 2.x app a second time, and a 2.x root fired 1.x
handlers rendered inside it. Both versions used the same key and the same
walk; nothing on the event object is consulted by 1.x, so no mark can stop
it. The only lever is a key it does not look for.

Compiled output (Babel and native) and the runtime (`addEvent`,
`eventHandler`) now stamp `_$$<type>` / `_$$<type>Data`. `_$` is the
existing Solid-owned expando family (`_$host`, `_$owner`, `_$classes`,
`_$styles`, `_$multiple`); the extra `$` gives event keys their own
namespace so a custom delegated event can never collide with one of those.

The key, the `_$SOLID_EVENT_OWNER` mark, and the walk rules are documented
in client.ts as the delegated-event wire contract: shared by every Solid
copy on a page and frozen going forward, so two bundles of the same major
— or a future major — coordinate through the existing mark rather than
needing a new key.

Regression coverage carries a faithful copy of the 1.x walker on
`document` beside a 2.x root (light DOM, open shadow root, 1.x content
nested in a 2.x root) and loads the runtime twice as separate module
instances to pin same-major nesting: single dispatch, stopPropagation
across instances, outer handlers above the inner root still firing.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
@ryansolid
ryansolid force-pushed the fix/delegated-event-key branch 2 times, most recently from b16f13b to 6d2bdeb Compare September 17, 2026 19:10
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35263342049

Warning

No base build found for commit 7624320 on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 71.304%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1035
Covered Lines: 783
Line Coverage: 75.65%
Relevant Branches: 798
Covered Branches: 524
Branch Coverage: 65.66%
Branches in Coverage %: Yes
Coverage Strength: 15.06 hits per line

💛 - Coveralls

@ryansolid
ryansolid merged commit d60ec6f into next Sep 17, 2026
7 checks passed
@ryansolid
ryansolid deleted the fix/delegated-event-key branch September 17, 2026 19:32
ryansolid added a commit that referenced this pull request Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants