From 540dfdbb845d1267f184ba9f4f9ea51bef4b6f4c Mon Sep 17 00:00:00 2001 From: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:40:12 -0300 Subject: [PATCH] fix(player): remove host listeners in controls destroy createControls attaches mousemove and mouseleave listeners to the host element to drive the auto-hide behavior, but destroy only tore down the document-level listeners. The host handlers were also anonymous, so they could not have been removed even if destroy tried. When the `controls` attribute is toggled off then on, the player calls destroy then re-runs createControls on the same persistent host, so each cycle leaks a duplicate mousemove/mouseleave pair. The duplicates fight over the hfp-hidden class and the overlay flickers instead of hiding cleanly after the idle timeout. Name the two host handlers and remove them in destroy, mirroring how the document listeners are torn down. Adds a test asserting every host listener added is removed with the same reference, plus a behavioral test that a mousemove after destroy no longer revives the overlay. --- packages/player/src/controls.test.ts | 62 ++++++++++++++++++++++++++++ packages/player/src/controls.ts | 12 ++++-- 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 packages/player/src/controls.test.ts diff --git a/packages/player/src/controls.test.ts b/packages/player/src/controls.test.ts new file mode 100644 index 000000000..0ce2ab537 --- /dev/null +++ b/packages/player/src/controls.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; +import { type ControlsCallbacks, createControls } from "./controls"; + +function noopCallbacks(): ControlsCallbacks { + return { + onPlay: () => {}, + onPause: () => {}, + onSeek: () => {}, + onSpeedChange: () => {}, + onMuteToggle: () => {}, + onVolumeChange: () => {}, + }; +} + +describe("createControls host listeners", () => { + it("removes every host listener it added on destroy", () => { + const host = document.createElement("div"); + document.body.appendChild(host); + + const addSpy = vi.spyOn(host, "addEventListener"); + const removeSpy = vi.spyOn(host, "removeEventListener"); + + const api = createControls(host, noopCallbacks()); + + // Capture the exact handler references registered on the host element. + const added = new Map(); + for (const [type, handler] of addSpy.mock.calls) { + added.set(type, handler as EventListenerOrEventListenerObject); + } + expect(added.has("mousemove")).toBe(true); + expect(added.has("mouseleave")).toBe(true); + + api.destroy(); + + // Each host listener must be torn down with the same reference; anonymous + // handlers (the previous bug) could never be removed, so toggling the + // `controls` attribute leaked a duplicate pair on every cycle. + for (const [type, handler] of added) { + expect(removeSpy).toHaveBeenCalledWith(type, handler); + } + + host.remove(); + }); + + it("stops reacting to host mousemove after destroy", () => { + const host = document.createElement("div"); + document.body.appendChild(host); + + const api = createControls(host, noopCallbacks()); + const controls = host.querySelector(".hfp-controls"); + expect(controls).not.toBeNull(); + + api.destroy(); + + // A mousemove after destroy must not revive the controls overlay. + controls!.classList.add("hfp-hidden"); + host.dispatchEvent(new Event("mousemove")); + expect(controls!.classList.contains("hfp-hidden")).toBe(true); + + host.remove(); + }); +}); diff --git a/packages/player/src/controls.ts b/packages/player/src/controls.ts index 7351cf0bd..7e2c79df4 100644 --- a/packages/player/src/controls.ts +++ b/packages/player/src/controls.ts @@ -330,13 +330,15 @@ export function createControls( }; const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent; - host.addEventListener("mousemove", () => { + const onHostMouseMove = () => { controls.classList.remove("hfp-hidden"); startHideTimer(); - }); - host.addEventListener("mouseleave", () => { + }; + const onHostMouseLeave = () => { if (isPlaying) controls.classList.add("hfp-hidden"); - }); + }; + host.addEventListener("mousemove", onHostMouseMove); + host.addEventListener("mouseleave", onHostMouseLeave); return { updateTime(current: number, duration: number) { @@ -389,6 +391,8 @@ export function createControls( document.removeEventListener("touchmove", onVolumeTouchMove); document.removeEventListener("touchend", onVolumeTouchEnd); document.removeEventListener("click", onDocClick); + host.removeEventListener("mousemove", onHostMouseMove); + host.removeEventListener("mouseleave", onHostMouseLeave); if (hideTimeout) clearTimeout(hideTimeout); }, };