Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/delegated-event-restore.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 31 additions & 28 deletions packages/web/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
106 changes: 106 additions & 0 deletions packages/web/test/delegated-event-restore.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(() => <section><button ref={btn} onClick={() => {}} /></section>, 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(() => <button ref={btn} onClick={() => {}} />, 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(() => <button ref={btn} onClick={() => { 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(() => <button ref={btn} onClick={() => 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(() => <div><button ref={a} onClick={() => {}} /><button ref={b} /></div>, 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(() => <div ref={slot} onClick={() => calls.push("outer")} />, outerRoot);
const innerRoot = document.createElement("div");
slot.appendChild(innerRoot);
const disposeInner = render(() => <button ref={btn} onClick={() => 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();
});
});
Loading