Skip to content

Commit 58807a7

Browse files
committed
Fix document event listener leak in onDriverClick (#452)
onDriverClick attached five capture-phase listeners to document and never removed them. Since renderPopover rebuilds the popover every step, the listeners accumulated on document for the lifetime of the page. Track each element's handler in a WeakMap and remove it on teardown (destroyOverlay, destroyPopover, and before re-rendering the popover). Also remove the keydown focus-trap listener in destroyEvents, which was added in initEvents but never cleaned up.
1 parent 431c036 commit 58807a7

4 files changed

Lines changed: 203 additions & 18 deletions

File tree

src/events.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ function onKeyup(e: KeyboardEvent) {
6464
}
6565
}
6666

67+
// The pointer events we intercept to make sure no external library ever hears
68+
// about a click before driver.js does. `click` carries the actual handler; the
69+
// rest are only suppressed.
70+
const DRIVER_CLICK_EVENTS = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"] as const;
71+
72+
// Associates each driver element with the single document-level handler that
73+
// was registered on its behalf, so it can be removed when the element is torn
74+
// down. A WeakMap keyed by the element avoids leaking handlers onto `document`
75+
// (the popover is rebuilt every step) and needs no id bookkeeping on the DOM.
76+
const driverClickHandlers = new WeakMap<Element, (e: MouseEvent | PointerEvent) => void>();
77+
6778
/**
6879
* Attaches click handler to the elements created by driver.js. It makes
6980
* sure to give the listener the first chance to handle the event, and
@@ -79,7 +90,11 @@ export function onDriverClick(
7990
listener: (pointer: MouseEvent | PointerEvent) => void,
8091
shouldPreventDefault?: (target: HTMLElement) => boolean
8192
) {
82-
const listenerWrapper = (e: MouseEvent | PointerEvent, listener?: (pointer: MouseEvent | PointerEvent) => void) => {
93+
// Defensive: if this element somehow already has a handler attached, remove
94+
// it first so we never register duplicates.
95+
destroyDriverClick(element);
96+
97+
const handler = (e: MouseEvent | PointerEvent) => {
8398
const target = e.target as HTMLElement;
8499
if (!element.contains(target)) {
85100
return;
@@ -91,26 +106,34 @@ export function onDriverClick(
91106
e.stopImmediatePropagation();
92107
}
93108

94-
listener?.(e);
109+
// Only the actual click should invoke the user's listener; the other
110+
// events exist purely to suppress interaction beneath the overlay/popover.
111+
if (e.type === "click") {
112+
listener?.(e);
113+
}
95114
};
96115

97116
// We want to be the absolute first one to hear about the event
98117
const useCapture = true;
99118

100-
// Events to disable
101-
document.addEventListener("pointerdown", listenerWrapper, useCapture);
102-
document.addEventListener("mousedown", listenerWrapper, useCapture);
103-
document.addEventListener("pointerup", listenerWrapper, useCapture);
104-
document.addEventListener("mouseup", listenerWrapper, useCapture);
105-
106-
// Actual click handler
107-
document.addEventListener(
108-
"click",
109-
e => {
110-
listenerWrapper(e, listener);
111-
},
112-
useCapture
113-
);
119+
for (const type of DRIVER_CLICK_EVENTS) {
120+
document.addEventListener(type, handler, useCapture);
121+
}
122+
123+
driverClickHandlers.set(element, handler);
124+
}
125+
126+
export function destroyDriverClick(element: Element) {
127+
const handler = driverClickHandlers.get(element);
128+
if (!handler) {
129+
return;
130+
}
131+
132+
for (const type of DRIVER_CLICK_EVENTS) {
133+
document.removeEventListener(type, handler, true);
134+
}
135+
136+
driverClickHandlers.delete(element);
114137
}
115138

116139
export function initEvents() {
@@ -122,6 +145,7 @@ export function initEvents() {
122145

123146
export function destroyEvents() {
124147
window.removeEventListener("keyup", onKeyup);
148+
window.removeEventListener("keydown", trapFocus);
125149
window.removeEventListener("resize", requireRefresh);
126150
window.removeEventListener("scroll", requireRefresh);
127151
}

src/overlay.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { easeInOutQuad } from "./utils";
2-
import { onDriverClick } from "./events";
2+
import { destroyDriverClick, onDriverClick } from "./events";
33
import { emit } from "./emitter";
44
import { getConfig } from "./config";
55
import { getState, setState } from "./state";
@@ -173,6 +173,7 @@ function generateStageSvgPathString(stage: StageDefinition) {
173173
export function destroyOverlay() {
174174
const overlaySvg = getState("__overlaySvg");
175175
if (overlaySvg) {
176+
destroyDriverClick(overlaySvg);
176177
overlaySvg.remove();
177178
}
178179
}

src/popover.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Config, DriverHook, getConfig, getCurrentDriver } from "./config";
22
import { Driver, DriveStep } from "./driver";
33
import { emit } from "./emitter";
4-
import { onDriverClick } from "./events";
4+
import { destroyDriverClick, onDriverClick } from "./events";
55
import { repositionPopover } from "./position";
66
import { getState, setState, State } from "./state";
77
import { bringInView, getFocusableElements } from "./utils";
@@ -63,6 +63,7 @@ export function hidePopover() {
6363
export function renderPopover(element: Element, step: DriveStep) {
6464
let popover = getState("popover");
6565
if (popover) {
66+
destroyDriverClick(popover.wrapper);
6667
document.body.removeChild(popover.wrapper);
6768
}
6869

@@ -331,5 +332,6 @@ export function destroyPopover() {
331332
return;
332333
}
333334

335+
destroyDriverClick(popover.wrapper);
334336
popover.wrapper.parentElement?.removeChild(popover.wrapper);
335337
}

tests/events.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { createDriver, navButton, nextFrame, SAMPLE_STEPS, useDriverHarness } from "./utils";
3+
4+
useDriverHarness();
5+
6+
afterEach(() => {
7+
vi.restoreAllMocks();
8+
});
9+
10+
// The document-level listeners driver.js attaches to intercept clicks.
11+
const DRIVER_EVENT_TYPES = ["click", "pointerdown", "mousedown", "pointerup", "mouseup"];
12+
13+
type ListenerRecord = {
14+
type: string;
15+
listener: EventListenerOrEventListenerObject;
16+
capture: boolean;
17+
};
18+
19+
function normalizeCapture(options?: boolean | AddEventListenerOptions | EventListenerOptions): boolean {
20+
if (typeof options === "boolean") {
21+
return options;
22+
}
23+
24+
return !!options?.capture;
25+
}
26+
27+
// Wraps document.add/removeEventListener so we can assert that every driver
28+
// listener that gets attached is also detached, i.e. nothing leaks onto the
29+
// document across steps and teardown.
30+
function trackDocumentListeners() {
31+
const added: ListenerRecord[] = [];
32+
const removed: ListenerRecord[] = [];
33+
34+
const originalAdd = document.addEventListener.bind(document);
35+
const originalRemove = document.removeEventListener.bind(document);
36+
37+
vi.spyOn(document, "addEventListener").mockImplementation((type, listener, options) => {
38+
if (DRIVER_EVENT_TYPES.includes(type)) {
39+
added.push({
40+
type,
41+
listener: listener as EventListenerOrEventListenerObject,
42+
capture: normalizeCapture(options),
43+
});
44+
}
45+
46+
return originalAdd(type, listener as EventListener, options);
47+
});
48+
49+
vi.spyOn(document, "removeEventListener").mockImplementation((type, listener, options) => {
50+
if (DRIVER_EVENT_TYPES.includes(type)) {
51+
removed.push({
52+
type,
53+
listener: listener as EventListenerOrEventListenerObject,
54+
capture: normalizeCapture(options),
55+
});
56+
}
57+
58+
return originalRemove(type, listener as EventListener, options);
59+
});
60+
61+
function liveCount(): number {
62+
const outstanding = [...removed];
63+
let live = 0;
64+
65+
for (const record of added) {
66+
const idx = outstanding.findIndex(
67+
candidate =>
68+
candidate.type === record.type &&
69+
candidate.listener === record.listener &&
70+
candidate.capture === record.capture
71+
);
72+
73+
if (idx === -1) {
74+
live++;
75+
} else {
76+
outstanding.splice(idx, 1);
77+
}
78+
}
79+
80+
return live;
81+
}
82+
83+
return { liveCount };
84+
}
85+
86+
describe("document listener cleanup", () => {
87+
it("attaches document click listeners while the tour is active", () => {
88+
const tracker = trackDocumentListeners();
89+
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
90+
d.drive();
91+
92+
expect(tracker.liveCount()).toBeGreaterThan(0);
93+
});
94+
95+
it("removes every document click listener once the tour is destroyed", () => {
96+
const tracker = trackDocumentListeners();
97+
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
98+
d.drive();
99+
d.destroy();
100+
101+
expect(tracker.liveCount()).toBe(0);
102+
});
103+
104+
it("does not accumulate document listeners as the tour moves between steps", () => {
105+
const tracker = trackDocumentListeners();
106+
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
107+
d.drive();
108+
109+
const afterFirstStep = tracker.liveCount();
110+
111+
navButton("next")?.click();
112+
navButton("next")?.click();
113+
114+
expect(tracker.liveCount()).toBe(afterFirstStep);
115+
});
116+
117+
it("removes the keydown focus-trap listener when destroyed", () => {
118+
const removeSpy = vi.spyOn(window, "removeEventListener");
119+
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
120+
d.drive();
121+
d.destroy();
122+
123+
expect(removeSpy.mock.calls.some(([type]) => type === "keydown")).toBe(true);
124+
});
125+
});
126+
127+
describe("overlay pointer suppression", () => {
128+
it("prevents default on pointer events over the overlay to block page interaction", async () => {
129+
const d = createDriver({ animate: false, steps: SAMPLE_STEPS });
130+
d.drive();
131+
await nextFrame();
132+
133+
const path = document.querySelector(".driver-overlay path");
134+
expect(path).not.toBeNull();
135+
136+
for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup"]) {
137+
const event = new MouseEvent(type, { bubbles: true, cancelable: true });
138+
path!.dispatchEvent(event);
139+
expect(event.defaultPrevented).toBe(true);
140+
}
141+
});
142+
143+
it("still routes overlay clicks to the click handler", async () => {
144+
const onNextClick = vi.fn();
145+
const d = createDriver({
146+
animate: false,
147+
overlayClickBehavior: "nextStep",
148+
steps: SAMPLE_STEPS,
149+
onNextClick,
150+
});
151+
d.drive();
152+
await nextFrame();
153+
154+
document.querySelector(".driver-overlay path")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
155+
156+
expect(onNextClick).toHaveBeenCalledTimes(1);
157+
});
158+
});

0 commit comments

Comments
 (0)