diff --git a/.changeset/delegated-event-restore.md b/.changeset/delegated-event-restore.md new file mode 100644 index 000000000..d86d7fc24 --- /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. 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 9b775a5e0..29dfd81c3 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -2975,7 +2975,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, @@ -3040,8 +3039,9 @@ function eventHandler(e, container, state) { } // 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); + // Later native listeners (document, window, outer roots) see the real getters. + delete e.target; + delete e.currentTarget; } 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..ada3773a7 --- /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.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(); + }); +});