Skip to content

Commit a037505

Browse files
fix(player): clean up controls on destroy (#1407)
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
1 parent 1c47ba9 commit a037505

3 files changed

Lines changed: 100 additions & 4 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { type ControlsCallbacks, createControls } from "./controls";
3+
4+
function noopCallbacks(): ControlsCallbacks {
5+
return {
6+
onPlay: () => {},
7+
onPause: () => {},
8+
onSeek: () => {},
9+
onSpeedChange: () => {},
10+
onMuteToggle: () => {},
11+
onVolumeChange: () => {},
12+
};
13+
}
14+
15+
describe("createControls host listeners", () => {
16+
it("removes every host listener it added on destroy", () => {
17+
const host = document.createElement("div");
18+
document.body.appendChild(host);
19+
20+
const addSpy = vi.spyOn(host, "addEventListener");
21+
const removeSpy = vi.spyOn(host, "removeEventListener");
22+
23+
const api = createControls(host, noopCallbacks());
24+
25+
// Capture the exact handler references registered on the host element.
26+
const added = new Map<string, EventListenerOrEventListenerObject>();
27+
for (const [type, handler] of addSpy.mock.calls) {
28+
added.set(type, handler as EventListenerOrEventListenerObject);
29+
}
30+
expect(added.has("mousemove")).toBe(true);
31+
expect(added.has("mouseleave")).toBe(true);
32+
33+
api.destroy();
34+
35+
// Each host listener must be torn down with the same reference; anonymous
36+
// handlers (the previous bug) could never be removed, so toggling the
37+
// `controls` attribute leaked a duplicate pair on every cycle.
38+
for (const [type, handler] of added) {
39+
expect(removeSpy).toHaveBeenCalledWith(type, handler);
40+
}
41+
42+
host.remove();
43+
});
44+
45+
it("stops reacting to host mousemove after destroy", () => {
46+
const host = document.createElement("div");
47+
document.body.appendChild(host);
48+
49+
const api = createControls(host, noopCallbacks());
50+
const controls = host.querySelector<HTMLElement>(".hfp-controls");
51+
expect(controls).not.toBeNull();
52+
53+
api.destroy();
54+
55+
// A mousemove after destroy must not revive the controls overlay.
56+
controls!.classList.add("hfp-hidden");
57+
host.dispatchEvent(new Event("mousemove"));
58+
expect(controls!.classList.contains("hfp-hidden")).toBe(true);
59+
60+
host.remove();
61+
});
62+
63+
it("removes its controls element from the host on destroy", () => {
64+
const host = document.createElement("div");
65+
document.body.appendChild(host);
66+
67+
const api = createControls(host, noopCallbacks());
68+
const controls = host.querySelector<HTMLElement>(".hfp-controls");
69+
expect(controls).not.toBeNull();
70+
71+
api.destroy();
72+
73+
expect(host.querySelector(".hfp-controls")).toBeNull();
74+
expect(controls!.isConnected).toBe(false);
75+
76+
host.remove();
77+
});
78+
});

packages/player/src/controls.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,13 +330,15 @@ export function createControls(
330330
};
331331

332332
const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
333-
host.addEventListener("mousemove", () => {
333+
const onHostMouseMove = () => {
334334
controls.classList.remove("hfp-hidden");
335335
startHideTimer();
336-
});
337-
host.addEventListener("mouseleave", () => {
336+
};
337+
const onHostMouseLeave = () => {
338338
if (isPlaying) controls.classList.add("hfp-hidden");
339-
});
339+
};
340+
host.addEventListener("mousemove", onHostMouseMove);
341+
host.addEventListener("mouseleave", onHostMouseLeave);
340342

341343
return {
342344
updateTime(current: number, duration: number) {
@@ -389,7 +391,10 @@ export function createControls(
389391
document.removeEventListener("touchmove", onVolumeTouchMove);
390392
document.removeEventListener("touchend", onVolumeTouchEnd);
391393
document.removeEventListener("click", onDocClick);
394+
host.removeEventListener("mousemove", onHostMouseMove);
395+
host.removeEventListener("mouseleave", onHostMouseLeave);
392396
if (hideTimeout) clearTimeout(hideTimeout);
397+
controls.remove();
393398
},
394399
};
395400
}

packages/player/src/hyperframes-player.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,6 +1467,19 @@ describe("HyperframesPlayer volume and mute", () => {
14671467
expect(slider.getAttribute("tabindex")).toBe("0");
14681468
});
14691469

1470+
it("removes and recreates one controls bar when the controls attribute toggles", () => {
1471+
document.body.appendChild(player);
1472+
1473+
player.setAttribute("controls", "");
1474+
expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(1);
1475+
1476+
player.removeAttribute("controls");
1477+
expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(0);
1478+
1479+
player.setAttribute("controls", "");
1480+
expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(1);
1481+
});
1482+
14701483
it("dispatches volumechange when muted toggles (HTML5 spec)", () => {
14711484
document.body.appendChild(player);
14721485

0 commit comments

Comments
 (0)