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(() => {}} />, root); + btn.click(); + dispose(); + outer.remove(); + expect(seen[0]).toBe(outer); + }); + + test("a throwing handler still restores currentTarget and target", () => { + const root = document.createElement("div"); + document.body.appendChild(root); + let btn!: HTMLButtonElement; + const dispose = render(() => { throw new Error("boom"); }} />, root); + const e = new MouseEvent("click", { bubbles: true }); + const onError = (ev: ErrorEvent) => ev.preventDefault(); + window.addEventListener("error", onError); + let seen: any; + document.addEventListener("click", ev => (seen = [ev.currentTarget, ev.target]), { once: true }); + btn.dispatchEvent(e); + window.removeEventListener("error", onError); + expect(seen).toEqual([document, btn]); + expect(Object.getOwnPropertyNames(e)).not.toContain("currentTarget"); + expect(Object.getOwnPropertyNames(e)).not.toContain("target"); + dispose(); + root.remove(); + }); +}); + +describe("re-dispatching a delegated event object", () => { + test.fails("the same Event object dispatched twice runs the handler twice", () => { + const root = document.createElement("div"); + document.body.appendChild(root); + let btn!: HTMLButtonElement; + let calls = 0; + const dispose = render(() => calls++} />, root); + const e = new MouseEvent("click", { bubbles: true }); + btn.dispatchEvent(e); + btn.dispatchEvent(e); + expect(calls).toBe(2); + dispose(); + root.remove(); + }); + test("re-dispatching to another element reports the new target", () => { + const root = document.createElement("div"); + document.body.appendChild(root); + let a!: HTMLButtonElement, b!: HTMLButtonElement; + const targets: any[] = []; + const dispose = render(() => {}} />, root); + const e = new MouseEvent("click", { bubbles: true }); + a.dispatchEvent(e); + const native: any[] = []; + b.addEventListener("click", ev => native.push(ev.target)); + b.dispatchEvent(e); + expect(native[0]).toBe(b); + dispose(); + root.remove(); + }); + + test.fails("nested roots each run once per dispatch of the same Event object", () => { + const outerRoot = document.createElement("div"); + document.body.appendChild(outerRoot); + let slot!: HTMLDivElement, btn!: HTMLButtonElement; + const calls: string[] = []; + const disposeOuter = render(() => calls.push("outer")} />, outerRoot); + const innerRoot = document.createElement("div"); + slot.appendChild(innerRoot); + const disposeInner = render(() => calls.push("inner")} />, innerRoot); + const e = new MouseEvent("click", { bubbles: true }); + btn.dispatchEvent(e); + btn.dispatchEvent(e); + expect(calls).toEqual(["inner", "outer", "inner", "outer"]); + disposeInner(); + disposeOuter(); + outerRoot.remove(); + }); +});