Skip to content

fix(youtube-video-element): destroy the player on disconnect to fix memory leak - #256

Open
jv8-alt wants to merge 1 commit into
muxinc:mainfrom
jv8-alt:fix/youtube-destroy-player-on-disconnect
Open

fix(youtube-video-element): destroy the player on disconnect to fix memory leak#256
jv8-alt wants to merge 1 commit into
muxinc:mainfrom
jv8-alt:fix/youtube-destroy-player-on-disconnect

Conversation

@jv8-alt

@jv8-alt jv8-alt commented Sep 3, 2026

Copy link
Copy Markdown

Destroys the YouTube player when the element leaves the DOM, and cleans up the pollers load() leaves running.

The bug

youtube-video-element defines no disconnectedCallback, and its only api.destroy() is unreachable except when src is cleared. So every mount/unmount cycle leaks a player.

Two independent things retain the detached <iframe>:

  1. The API's registries. It keys every player by element id and never releases it without destroy():

    this.g.id=ja(this.g);T[this.g.id]=this;   // constructor: register
    this.g&&this.g.id&&(T[this.g.id]=null);   // destroy(): the only place it clears

    this.g is the iframe (n.getIframe=function(){return this.g}).

  2. The two setInterval pollers at the end of load(). Neither id is stored; the 50ms seek poller has no cancel path at all, and the 100ms progress poller only self-cancels once fully buffered. Both closures capture this, so the timer queue keeps the element — and through it the player and the iframe — alive on its own.

Found via heap snapshots on a production React app (through react-player v3): Detached HTMLIFrameElement nodes accumulating across route changes.

vimeo-video-element in this repo already handles (1). youtube-video-element never got the same treatment.

Full destroy() and the second registry, verbatim from www-widgetapi.js (build ea6f527e)
n.destroy=function(){
  this.g&&this.g.id&&(T[this.g.id]=null);
  var a=this.i;a&&typeof a.dispose=="function"&&a.dispose();
  if(this.j){a=this.j;var b=this.g,c=b.parentNode;c&&c.replaceChild(a,b)}
  else(a=this.g)&&a.parentNode&&a.parentNode.removeChild(a);
  Z&&(Z[this.id]=null);
  this.options=null;
  this.g&&this.v&&this.g.removeEventListener("load",this.v);
  this.j=this.g=null
};

A global message listener routes postMessages through a second registry, Z, cleared in the same method:

ib.addEventListener("message",function(d){ 
var k=Z[h.id];if(k&&d.origin===k.O)

The changes

1. connectedCallback / disconnectedCallback — mirroring vimeo-video-element. Destroy the player and reset load state on disconnect; reload on reconnect, because load() only runs from attributeChangedCallback, which doesn't fire on a DOM move.

2. A #loadId generation counter. load() awaits the API script and then loadComplete. An element disconnected during either window would still attach a player, or start pollers for one, afterwards. Bumping #loadId on disconnect and on each load makes the superseded attempt bail at both points.

3. #timers + #clearTimers(). Tracks both intervals, clears them on disconnect and before starting new ones so a reload can't stack them.

Change 3 is pre-existing on main rather than introduced here — happy to split it out if you'd prefer, though reload-on-reconnect is what turns it from a leak into a stacking leak, so they're awkward to review apart.

Testing

Three cases added to test/test.js in the existing zora/fixture() style: destroy-on-disconnect, new-player-on-reconnect, disconnect-before-ready.

I could not run wet test — it needs network access to YouTube, which my sandbox blocks. Worth confirming in CI.

I verified the logic offline with jsdom instead: the real element module, window.YT stubbed with a fake Player (loadScript() short-circuits when the global exists, so nothing hits the network), asserting on player construction, destroy() calls, and live interval counts.

players leaked live intervals after teardown
main today 2 of 2 4
this PR 0 0
Harness output — 15/15 with the fix, 4/15 without
ok   - one player created on first load (got 1)
ok   - element holds a player reference
ok   - iframe present after load
ok   - destroy() called exactly once on disconnect (got 1)
ok   - api reference released on disconnect
ok   - isLoaded reset on disconnect
ok   - iframe removed by destroy()
ok   - a second player is created on reconnect (got 2)
ok   - reconnected element holds a NEW player
ok   - iframe rebuilt on reconnect
ok   - second disconnect destroys the second player (got 2)
ok   - removing an already-disconnected element does not double-destroy
ok   - disconnect before ready leaves no player
ok   - every player created was destroyed (2 created, 0 leaked)
ok   - no polling intervals left running after teardown (0 live)

With the source change reverted, 11 of these fail, including destroy() called exactly once on disconnect (got 0) and every player created was destroyed (2 created, 2 leaked).

npx eslint youtube-video-element.js is clean.

Worth a maintainer's eye

  • vimeo-video-element likely has the same in-flight race — it nulls #loadRequested on disconnect, but its pending load() still proceeds to attach a player. Left alone to keep this PR to one package.
  • Other packages create third-party players with no disconnectedCallback: wistia, twitch, spotify, tiktok, jwplayer, cloudflare. I haven't checked whether each SDK leaks the same way — flagging the pattern, not claiming the bug.

Note

Medium Risk
Touches core load() and custom-element lifecycle; behavior changes on unmount/remount and during async load, but scope is limited to youtube-video-element with targeted tests.

Overview
Fixes a memory leak where removing <youtube-video> from the DOM left YouTube iframe players and polling timers alive.

Lifecycle: Adds disconnectedCallback to call api.destroy(), clear seek/progress setInterval handlers, reset load state, and bump a #loadId so in-flight load() work stops before attaching another player. Adds connectedCallback to call load() again after a DOM move (no attribute change). Each load() now always destroys the previous player before creating a new one, not only when src is cleared.

Race safety: After awaiting the iframe API script or loadComplete, load() exits if #loadId changed (disconnect or superseding load). Interval IDs are stored in #timers and cleared on disconnect and before starting new pollers.

Tests: Three zora tests cover destroy on disconnect, fresh player on reconnect, and disconnect mid-load without throwing.

Reviewed by Cursor Bugbot for commit 3992fa2. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

@staypuft3 is attempting to deploy a commit to the Mux Team on Vercel.

A member of the Team first needs to authorize it.

@snyk-io

snyk-io Bot commented Sep 3, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Comment thread packages/youtube-video-element/youtube-video-element.js
@jv8-alt
jv8-alt force-pushed the fix/youtube-destroy-player-on-disconnect branch from 1c30859 to d50f376 Compare September 3, 2026 02:34

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.

Reviewed by Cursor Bugbot for commit d50f376. Configure here.

Comment thread packages/youtube-video-element/youtube-video-element.js
The YouTube iframe API keeps every player it creates in module-scoped
registries, so removing the element is not enough to release the
<iframe>: it stays detached but reachable from `window.YT`, along with
everything the player's event handlers close over. The element defines
no `disconnectedCallback`, and `api.destroy()` is otherwise only
reachable from `load()` when `src` is cleared, so every mount/unmount
cycle leaks a player.

- Add `connectedCallback`/`disconnectedCallback` following the pattern
  already used in vimeo-video-element: destroy the player and reset the
  load state on disconnect, reload on reconnect so a DOM move still
  works (`load()` is otherwise only triggered by attributeChangedCallback,
  which does not fire on a move).

- Always release the previous player in `load()`. A new one is
  constructed unconditionally further down, so `oldApi` is never reused,
  but it was only destroyed when `src` was empty. Every `src` change
  therefore orphaned a player in the registries holding its discarded
  iframe. Every attribute that triggers load() is part of the iframe URL,
  so the iframe was already being rebuilt in these cases; this only
  releases the player that was being dropped.

- Guard the load with a `#loadId` generation counter, bumped on
  disconnect and on each load, so an attempt superseded while awaiting
  the API script or `loadComplete` bails out at both points.

- Track and clear the two polling intervals started at the end of
  `load()`. Neither timer id was stored, so the 50ms seek poller had no
  cancel path at all and the 100ms progress poller only cancelled itself
  once fully buffered. Both closures capture `this`, keeping the element
  and its player alive independently of the registries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jv8-alt
jv8-alt force-pushed the fix/youtube-destroy-player-on-disconnect branch from d50f376 to 3992fa2 Compare September 3, 2026 03:06
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.

2 participants