From b0dffd082c339eddbdc0538464ee1c9c57958132 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Mon, 26 Oct 2026 20:25:28 -0500 Subject: [PATCH] fix(web): delegated dispatch restores the event's currentTarget and target --- .changeset/delegated-event-restore.md | 5 + packages/web/src/client.ts | 59 +++++----- .../web/test/delegated-event-restore.spec.tsx | 106 ++++++++++++++++++ 3 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 .changeset/delegated-event-restore.md create mode 100644 packages/web/test/delegated-event-restore.spec.tsx diff --git a/.changeset/delegated-event-restore.md b/.changeset/delegated-event-restore.md new file mode 100644 index 000000000..f9dc6a5c3 --- /dev/null +++ b/.changeset/delegated-event-restore.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Delegated dispatch removes its `currentTarget` and `target` overrides from the native event when its walk ends, even when a handler throws. Native listeners that run later in the same dispatch, such as ones on `document`, `window` or an element above the render root, read their own `currentTarget` again instead of the last node Solid visited. diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index c17db49c9..806935f89 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -2971,7 +2971,6 @@ function eventHandler(e, container, state) { let node = resumeNode || e.target; const key = EVENT_KEY + e.type; - const oriTarget = e.target; const retarget = value => Object.defineProperty(e, "target", { configurable: true, @@ -3009,35 +3008,39 @@ function eventHandler(e, container, state) { return node || owner; } }); - if (resumeNode) { - // If the boundary was the target, the inner walk already fired it. - // Resume above it so boundary handlers do not run twice. - if (resumeNode === e.target) - node = resumeNode._$host || resumeNode.parentNode || resumeNode.host; - if (node && node !== owner) walkUpTree(); - } else if (e.composedPath) { - const path = e.composedPath(); - if (path.length) { - retarget(path[0]); - for (let i = 0; i < path.length; i++) { - node = path[i]; - if (!handleNode()) break; - if (node._$host) { - node = node._$host; - // bubble up from portal mount instead of composedPath - walkUpTree(); - break; - } - if (node === owner || node.parentNode === owner) { - break; // don't bubble above root of event delegation + try { + if (resumeNode) { + // If the boundary was the target, the inner walk already fired it. + // Resume above it so boundary handlers do not run twice. + if (resumeNode === e.target) + node = resumeNode._$host || resumeNode.parentNode || resumeNode.host; + if (node && node !== owner) walkUpTree(); + } else if (e.composedPath) { + const path = e.composedPath(); + if (path.length) { + retarget(path[0]); + for (let i = 0; i < path.length; i++) { + node = path[i]; + if (!handleNode()) break; + if (node._$host) { + node = node._$host; + // bubble up from portal mount instead of composedPath + walkUpTree(); + break; + } + if (node === owner || node.parentNode === owner) { + break; // don't bubble above root of event delegation + } } - } - } else walkUpTree(); + } else walkUpTree(); + } + // fallback for browsers that don't support composedPath + else walkUpTree(); + } finally { + // Later native listeners (document, window, outer roots) see the real getters. + delete e.target; + delete e.currentTarget; } - // fallback for browsers that don't support composedPath - else walkUpTree(); - // Mixing portals and shadow dom can lead to a nonstandard target, so reset here. - retarget(oriTarget); } function insertExpression(parent, value, current, marker) { diff --git a/packages/web/test/delegated-event-restore.spec.tsx b/packages/web/test/delegated-event-restore.spec.tsx new file mode 100644 index 000000000..1936a111b --- /dev/null +++ b/packages/web/test/delegated-event-restore.spec.tsx @@ -0,0 +1,106 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { describe, expect, test } from "vitest"; +import { render } from "@solidjs/web"; + +describe("delegated dispatch restores the native Event", () => { + test("a native listener above the render root sees its own currentTarget", () => { + const root = document.createElement("div"); + document.body.appendChild(root); + let btn!: HTMLButtonElement; + const seen: any[] = []; + const listener = (e: Event) => seen.push(e.currentTarget); + document.addEventListener("click", listener); + const dispose = render(() =>
, root); + btn.click(); + document.removeEventListener("click", listener); + dispose(); + root.remove(); + expect(seen[0]).toBe(document); + }); + + test("a native listener on the root's ancestor sees its own currentTarget", () => { + const outer = document.createElement("div"); + const root = document.createElement("div"); + outer.appendChild(root); + document.body.appendChild(outer); + let btn!: HTMLButtonElement; + const seen: any[] = []; + outer.addEventListener("click", e => seen.push(e.currentTarget)); + const dispose = render(() =>