Skip to content

A destroyed provider leaves nothing playing - #46

Open
karngyan wants to merge 1 commit into
mainfrom
fix/provider-teardown-stops-playback
Open

A destroyed provider leaves nothing playing#46
karngyan wants to merge 1 commit into
mainfrom
fix/provider-teardown-stops-playback

Conversation

@karngyan

@karngyan karngyan commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

destroy() removed its listeners and then called el.remove(). Detaching an element does not pause it and does not release its source, so a destroyed provider left a live <mux-video> behind: still playing, still pulling HLS segments, and unreachable, because every control on the page talks to the provider that owned it.

React invokes a mount effect twice in development (mount, destroy, mount), so a player created with autoPlay ended up with two audio tracks a beat apart and only one of them steerable. Measured in a real browser by counting mux-video creations:

{"created": 2, "attached": 1}

The consuming site worked around it by dropping autoPlay, which only means nothing starts the orphan. The leak was still there for anyone who passes autoPlay, or whose element is already playing when a teardown happens: a route change mid-playback, a remount on a changed key, a fast refresh. A source change is not one of these, since it flows through swapSource, which keeps the element and never calls destroy().

What changed

src/mux/provider.ts and src/native/provider.ts, in destroy(), after the listeners come off and before el.remove():

  • Pause the element. This is the part that stops audible playback the moment teardown runs.
  • Drop the source. For mux that is el.removeAttribute("src"). A detached element that keeps pulling segments is a bandwidth leak even when it is silent.

Why clearing src is the release, and why disconnectedCallback is not enough

Read out of node_modules, not guessed:

  • MuxVideoBaseElement.attributeChangedCallback for src calls this.unload() when the old value was truthy and the new one is not. unload() is teardown(nativeEl, coreRef, this) in @mux/playback-core, which does engine.detachMedia(), engine.destroy(), then nativeEl.removeAttribute("src") and nativeEl.load(). That is the library's own release path.
  • disconnectedCallback() calls the same unload(), so in theory el.remove() alone would do it. In practice it loses a race: mux-video defers its setup by a microtask (await Promise.resolve() before this.load()), so an element mounted and destroyed in the same tick, which is exactly the React double effect, is disconnected before there is anything to tear down, and then finishes coming up, with autoplay, on a detached element. Clearing src first wins that race: a late initialize() with no src builds no engine and calls removeAttribute("src") on the inner <video> instead of loading it.
  • Two things that look right and are not. el.playbackId = undefined does not release anything: the playbackId getter falls back to parsing the current src, so the derived src is recomputed to the same URL and the attribute survives. el.unload() on its own releases the engine but leaves src in place, so the pending setup re-initializes it a microtask later.

The native provider already did removeAttribute("src") plus load(), and the load algorithm pauses, so it was not leaking. Its stop rode entirely on load(), which the provider itself swallows where that is unavailable, so it now pauses explicitly too rather than being fixed by side effect.

The other providers

Not touched, and not the same shape underneath:

  • YouTube and Vimeo hand teardown to the SDK's own destroy(), which takes the player iframe with it. Both already have tests for it (destroy tears down the player, tears down the player on destroy), and both guard the case where the SDK resolves after teardown.
  • Scenes removes the host iframe. Removing an iframe discards its browsing context along with the audio element inside it. That is the difference that matters here: a detached iframe stops, a detached media element does not.

Test

src/mux/provider.test.ts is new; src/native/provider.test.ts gains one case. jsdom decodes no media, so they assert the observable contract instead: pause() is called on the element, the element reports itself paused, it is detached, and its source is gone. One case waits a tick to prove the release survives mux-video finishing its own setup, and one runs the mount, destroy, mount sequence and checks the first element is left stopped while the second is live.

Against the unfixed providers:

 ❯ src/native/provider.test.ts (14 tests | 1 failed)
   × destroy pauses the element before dropping it
     → expected "spy" to be called at least once
 ❯ src/mux/provider.test.ts (5 tests | 4 failed)
   × destroy pauses the element it created
     → expected "spy" to be called at least once
   ✓ destroy detaches the element
   × destroy releases the source so the engine stops fetching
     → expected true to be false // Object.is equality
   × the released source stays released once mux-video finishes its setup
     → expected true to be false // Object.is equality
   × mount, destroy, mount leaves the first element stopped
     → expected "spy" to be called at least once

 Test Files  2 failed (2)
      Tests  5 failed | 14 passed (19)

destroy detaches the element passes on both sides, as it should: detaching was never the broken half.

With the fix:

 ✓ src/native/provider.test.ts (14 tests) 218ms
 ✓ src/mux/provider.test.ts (5 tests) 385ms

 Test Files  2 passed (2)
      Tests  19 passed (19)

Checks

Everything CI runs, locally, all green:

pnpm format:check   All matched files use Prettier code style!
pnpm typecheck      tsc --noEmit, clean
pnpm lint           eslint ., clean
pnpm test           Test Files  28 passed (28) / Tests  240 passed (240)
pnpm build          16 files, total: 144.90 kB / Build complete in 4834ms
pnpm build:demo     built in 9.26s / prerendered 5 routes + sitemap.xml

Not verified, and one thing found on the way

  • The two-audio-tracks symptom and its fix were reproduced and asserted in jsdom, which never decodes anything. The browser-level claim that the pause and the src clear silence a real orphan is reasoned from the mux and playback-core source, not measured in a browser.
  • YouTube's and Vimeo's destroy() removing their iframes is from their published API contracts and their existing fakes, not from a live SDK.
  • Found and deliberately left alone: mount() in the mux provider registers textTracks listeners without the typeof tt.addEventListener === "function" guard its native sibling carries, so it throws in jsdom, where textTracks is a plain array. The new test shims that in the test file rather than changing the provider, to keep this PR to the teardown bug. Worth a separate look, since it means consumers cannot mount a Mux player in their own jsdom tests.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Media playback now stops reliably when Mux or native video providers are removed.
    • Video sources are cleared during teardown to prevent continued playback and background segment fetching.
    • Improved handling for rapid mount-and-remove cycles.
  • Tests

    • Added coverage for autoplay teardown, source removal, and repeated mounting scenarios.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
kino 8a16c28 Commit Preview URL

Branch Preview URL
Aug 11 2026, 09:23 PM

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Mux and native provider destruction now pauses media and clears its source before element removal. New tests cover synchronous, asynchronous, and remount teardown cases.

Changes

Provider teardown

Layer / File(s) Summary
Pause and clear media during teardown
src/mux/provider.ts, src/native/provider.ts, .changeset/provider-teardown-stops-playback.md
Mux and native providers pause media, remove the src, and then detach the element. The changeset documents this behavior and existing teardown behavior for other providers.
Validate destruction behavior
src/mux/provider.test.ts, src/native/provider.test.ts
Tests verify paused playback, source removal, element detachment, deferred Mux setup handling, and safe mount–destroy–mount cycles.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: destroyed providers stop playback during teardown.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/provider-teardown-stops-playback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/provider-teardown-stops-playback.md:
- Line 5: Update the source-swap statement in the changeset description to
remove “a source swap” or explicitly qualify it as a full provider recreation,
since swapSource() keeps the existing element mounted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 81347a4c-ba3f-43f8-9b29-72cedab4e77a

📥 Commits

Reviewing files that changed from the base of the PR and between 611b066 and 3077a63.

📒 Files selected for processing (5)
  • .changeset/provider-teardown-stops-playback.md
  • src/mux/provider.test.ts
  • src/mux/provider.ts
  • src/native/provider.test.ts
  • src/native/provider.ts

Comment thread .changeset/provider-teardown-stops-playback.md Outdated
destroy() removed its listeners and detached the element, but detaching a
media element does not stop it. A removed <mux-video> 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) <noreply@anthropic.com>
@karngyan
karngyan force-pushed the fix/provider-teardown-stops-playback branch from 3077a63 to 8a16c28 Compare August 11, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant