From 8a16c28976f87d1d12b2ad2581e8efd6199cc893 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 12 Aug 2026 02:46:35 +0530 Subject: [PATCH] fix(mux,native): stop playback when a provider is destroyed destroy() removed its listeners and detached the element, but detaching a media element does not stop it. A removed keeps playing and keeps pulling HLS segments, and nothing on the page can reach it any more, because every control talks to the provider that owned it. React invokes a mount effect twice in development, so a player created with autoPlay would mount, tear down and mount again, and the discarded element played on underneath the live one until the tab closed. Pause the element and drop its source before removing it. For mux that means clearing the src attribute, which is what makes mux-video tear its playback engine down; the element's own disconnectedCallback cannot cover this, because mux-video finishes its setup a microtask after mount and a mount and teardown that land in the same tick get there first. The native provider already released its source, and now pauses as well, so its stop no longer rides entirely on load(). YouTube, Vimeo and scenes were already clean: the first two hand teardown to the SDK's own destroy(), which takes the player iframe with it, and scenes removes the host iframe, which discards the audio element inside it. Co-Authored-By: Claude Opus 5 (1M context) --- .../provider-teardown-stops-playback.md | 9 ++ src/mux/provider.test.ts | 87 +++++++++++++++++++ src/mux/provider.ts | 10 +++ src/native/provider.test.ts | 19 ++++ src/native/provider.ts | 6 ++ 5 files changed, 131 insertions(+) create mode 100644 .changeset/provider-teardown-stops-playback.md create mode 100644 src/mux/provider.test.ts diff --git a/.changeset/provider-teardown-stops-playback.md b/.changeset/provider-teardown-stops-playback.md new file mode 100644 index 0000000..45099dc --- /dev/null +++ b/.changeset/provider-teardown-stops-playback.md @@ -0,0 +1,9 @@ +--- +"@karnstack/kino": patch +--- + +Mux and native: a destroyed provider leaves nothing playing. `destroy()` removed its listeners and detached the element, but detaching a media element does not stop it. A removed `` keeps playing and keeps pulling HLS segments, and nothing on the page can reach it any more, because every control talks to the provider that owned it. React invokes a mount effect twice in development, so a player created with `autoPlay` would mount, tear down and mount again, and the discarded element played on underneath the live one, a beat apart, until the tab closed. The same thing happened to any player that was already playing when a route change, a remount on a changed `key` or a fast refresh tore it down. A source change is not one of these: it flows through `swapSource`, which keeps the element and never calls `destroy()`. + +Both providers now pause the element and drop its source before removing it. For mux that means clearing the `src` attribute, which is what makes `mux-video` tear its playback engine down; the element's own `disconnectedCallback` cannot cover this, because `mux-video` finishes its setup a microtask after mount and a mount and teardown that land in the same tick get there first. The native provider already released its source, and now pauses as well, so its stop no longer rides entirely on `load()`. + +The YouTube, Vimeo and scenes providers were already clean. The first two hand teardown to the SDK's own `destroy()`, which takes the player iframe with it, and scenes removes the host iframe, which discards the document and the audio element inside it. A detached iframe stops. A detached media element does not. diff --git a/src/mux/provider.test.ts b/src/mux/provider.test.ts new file mode 100644 index 0000000..98ad47a --- /dev/null +++ b/src/mux/provider.test.ts @@ -0,0 +1,87 @@ +import { expect, test, vi } from "vitest" +import { createMuxProvider } from "./provider" + +// jsdom hands back a plain array for HTMLMediaElement.textTracks rather than a +// TextTrackList, and mount() registers track listeners on it. Give the list the +// two methods it needs so the provider can mount here at all. +const mediaProto = HTMLMediaElement.prototype +const textTracks = Object.getOwnPropertyDescriptor(mediaProto, "textTracks")! +Object.defineProperty(mediaProto, "textTracks", { + configurable: true, + get(this: HTMLMediaElement) { + const list = textTracks.get!.call(this) as TextTrackList + const target = list as unknown as Record + if (list && typeof target.addEventListener !== "function") { + target.addEventListener = () => {} + target.removeEventListener = () => {} + } + return list + }, +}) + +// jsdom decodes no media, so playback here is a stand-in for it: the element +// reports itself playing until something calls pause() on it, which is the +// contract destroy() has to meet. +function mountPlaying() { + const host = document.createElement("div") + document.body.appendChild(host) + const provider = createMuxProvider({ playbackId: "abc123", autoPlay: true }) + provider.mount(host) + const el = host.querySelector("mux-video") as HTMLVideoElement + let paused = false + const pause = vi.fn(() => { + paused = true + }) + Object.defineProperty(el, "paused", { configurable: true, get: () => paused }) + Object.defineProperty(el, "pause", { configurable: true, value: pause }) + return { host, provider, el, pause } +} + +test("destroy pauses the element it created", () => { + const { provider, el, pause } = mountPlaying() + expect(el.paused).toBe(false) + provider.destroy() + expect(pause).toHaveBeenCalled() + expect(el.paused).toBe(true) +}) + +test("destroy detaches the element", () => { + const { host, provider, el } = mountPlaying() + provider.destroy() + expect(host.querySelector("mux-video")).toBeNull() + expect(el.isConnected).toBe(false) +}) + +test("destroy releases the source so the engine stops fetching", () => { + const { provider, el } = mountPlaying() + expect(el.getAttribute("src")).toContain("abc123") + provider.destroy() + expect(el.hasAttribute("src")).toBe(false) +}) + +test("the released source stays released once mux-video finishes its setup", async () => { + // mux-video loads its source a microtask after the attribute lands, so an + // element destroyed in the same tick it was mounted would otherwise come up + // playing after it was already thrown away. + const { provider, el } = mountPlaying() + provider.destroy() + await new Promise((r) => setTimeout(r, 0)) + expect(el.hasAttribute("src")).toBe(false) +}) + +test("mount, destroy, mount leaves the first element stopped", () => { + // React invokes a mount effect twice in development: mount, destroy, mount. + // The first element is unreachable afterwards, because every control on the + // page talks to the provider that owned it, so anything it is still doing + // plays over the second one until the tab closes. + const first = mountPlaying() + first.provider.destroy() + const second = mountPlaying() + expect(second.el).not.toBe(first.el) + expect(first.pause).toHaveBeenCalled() + expect(first.el.paused).toBe(true) + expect(first.el.isConnected).toBe(false) + expect(first.el.hasAttribute("src")).toBe(false) + expect(second.el.isConnected).toBe(true) + second.provider.destroy() +}) diff --git a/src/mux/provider.ts b/src/mux/provider.ts index f2efca6..c756297 100644 --- a/src/mux/provider.ts +++ b/src/mux/provider.ts @@ -471,6 +471,16 @@ export function createMuxProvider(opts: MuxProviderOptions): Provider { el.textTracks?.removeEventListener("addtrack", onTextTracksChanged) el.textTracks?.removeEventListener("removetrack", onTextTracksChanged) el.textTracks?.removeEventListener("change", onTextTracksChanged) + // Removing the element does not stop it: a detached keeps + // playing and keeps pulling segments, and nothing on the page can reach + // it any more. Pause, then drop the src, which is what makes mux-video + // tear its playback engine down. Both belong here rather than in the + // element's own disconnectedCallback, which is too late: mux-video + // finishes its setup a microtask after mount, so an element mounted and + // destroyed in the same tick (React runs a mount effect twice in + // development) comes up playing after it was already thrown away. + el.pause() + el.removeAttribute("src") el.remove() } el = null diff --git a/src/native/provider.test.ts b/src/native/provider.test.ts index 949d959..cb7bdc0 100644 --- a/src/native/provider.test.ts +++ b/src/native/provider.test.ts @@ -1,3 +1,4 @@ +import { expect, test, vi } from "vitest" import { createNativeProvider } from "./provider" function mount(provider: ReturnType) { @@ -150,3 +151,21 @@ test("destroy removes the video element from its host", () => { p.destroy() expect(host.querySelector("video")).toBeNull() }) + +test("destroy pauses the element before dropping it", () => { + // jsdom decodes no media, so playback here is a stand-in for it: the element + // reports itself playing until something calls pause() on it. + const p = createNativeProvider({ src: "clip.mp4", autoPlay: true }) + const { el } = mount(p) + let paused = false + const pause = vi.fn(() => { + paused = true + }) + Object.defineProperty(el, "paused", { configurable: true, get: () => paused }) + Object.defineProperty(el, "pause", { configurable: true, value: pause }) + p.destroy() + expect(pause).toHaveBeenCalled() + expect(el.paused).toBe(true) + expect(el.isConnected).toBe(false) + expect(el.hasAttribute("src")).toBe(false) +}) diff --git a/src/native/provider.ts b/src/native/provider.ts index 58a0c66..45f6f4a 100644 --- a/src/native/provider.ts +++ b/src/native/provider.ts @@ -394,6 +394,12 @@ export function createNativeProvider(opts: NativeProviderOptions): Provider { tt.removeEventListener("removetrack", onTextTracksChanged) tt.removeEventListener("change", onTextTracksChanged) } + // Removing the element does not stop it: a detached