Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/youtube-video-element/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,48 @@ test('t parameter - case insensitive', async function (t) {
t.equal(startParam, '171', 'start parameter is set from uppercase T parameter');
});

test('destroys the player when disconnected', async function (t) {
const video = await createVideoElement();
await video.loadComplete;

t.ok(video.api, 'has a player once loaded');
t.ok(video.shadowRoot.querySelector('iframe'), 'has an iframe once loaded');

video.remove();

t.equal(video.api, null, 'the player reference is released on disconnect');
t.equal(video.isLoaded, false, 'the element is no longer marked loaded');
t.equal(
video.shadowRoot.querySelector('iframe'),
null,
'destroy() removed the iframe from the shadow root'
);
});

test('creates a new player when reconnected', async function (t) {
const video = await createVideoElement();
await video.loadComplete;

const firstApi = video.api;
video.remove();
document.body.append(video);

await video.loadComplete;

t.ok(video.api, 'has a player again after reconnecting');
t.ok(video.api !== firstApi, 'a new player was created, not the destroyed one');
t.ok(video.shadowRoot.querySelector('iframe'), 'the iframe was rebuilt');
});

test('disconnecting before the player is ready does not throw', async function (t) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test creates the element, calls remove() right away, and asserts video.api === null immediately after. That assertion is true by construction, disconnectedCallback sets this.api = null unconditionally, regardless of whether the #loadId guard actually did its job.

It does not currently prove the thing it is meant to prove, that the in flight continuation (await loadScript(...) or await this.loadComplete) does not go on to build a second player after the element already disconnected.

To actually cover that race, the test needs to let that continuation resume after remove(), for example by awaiting a tick or intercepting the YT.Player construction, and then assert video.api is still null once it does. As written this passes even if the #loadId guard were removed entirely.

@jv8-alt jv8-alt Sep 22, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right, and that test was mine. api === null right after remove() is guaranteed by disconnectedCallback, and t.ok(true, …) doesn't check anything.

Rewritten in db5a5e3 as disconnecting mid-load does not build a player afterwards. It waits for YT.Player to exist, gives the pending load() 100ms to run, and only then asserts api is still null. To check it actually proves something, I deleted the #loadId guard: the old test still passes, and the new one fails.

I also added disconnecting before ready settles the pending loadComplete for the other fix. It waits for the player to be constructed, disconnects, and then races loadComplete against a 3s timeout. It fails on 3992fa2 (the promise stays pending) and passes with the fix, where it rejects with AbortError. It only checks that the promise doesn't hang, not that it rejects, so it won't flake if onReady happens to fire before the disconnect.

const video = await createVideoElement();
// Do not await loadComplete: the API may still be loading.
video.remove();

t.equal(video.api, null, 'no player is left behind');
t.ok(true, 'disconnecting mid-load did not throw');
});

function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down
68 changes: 61 additions & 7 deletions packages/youtube-video-element/youtube-video-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
loadComplete = new PublicPromise();
#loadRequested;
#hasLoaded;
#wasDisconnected = false;
#loadId = 0;
#timers = [];
#readyState = 0;
#seeking = false;
#seekComplete;
Expand All @@ -172,6 +175,12 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
async load() {
if (this.#loadRequested) return;

// Identifies this load attempt. `load()` awaits the API script, so the
// element can be disconnected (or asked to load again) while this one is
// still in flight; those bump #loadId and this attempt bails out below
// instead of attaching a player nothing will ever destroy.
const loadId = ++this.#loadId;

if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
Expand All @@ -193,12 +202,13 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement

let oldApi = this.api;
this.api = null;
// A new player is always constructed below, so the previous one is never
// reused. Release it rather than just dropping the reference, otherwise it
// stays in the API's registries holding its (now discarded) iframe.
// Removes the <iframe> containing the player.
oldApi?.destroy();

if (!this.src) {
// Removes the <iframe> containing the player.
oldApi?.destroy();
return;
}
if (!this.src) return;

this.#textTracksVideo = document.createElement('video');
this.textTracks = this.#textTracksVideo.textTracks;
Expand All @@ -223,6 +233,10 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
}

const YT = await loadScript(API_URL, API_GLOBAL, API_GLOBAL_READY);

// Superseded by a disconnect or a newer load() while awaiting the API.
if (loadId !== this.#loadId) return;

Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
this.api = new YT.Player(iframe, {
events: {
onReady: () => {
Expand Down Expand Up @@ -328,8 +342,14 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement

await this.loadComplete;

// Superseded while awaiting loadComplete; don't start pollers for a player
// that is already gone.
if (loadId !== this.#loadId) return;

this.#clearTimers();

let lastCurrentTime = 0;
setInterval(() => {
this.#timers.push(setInterval(() => {
const diff = Math.abs(this.currentTime - lastCurrentTime);
const bufferedEnd = this.buffered.end(this.buffered.length - 1);
if (this.seeking && bufferedEnd > 0.1) {
Expand All @@ -341,7 +361,7 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
this.dispatchEvent(new Event('seeking'));
}
lastCurrentTime = this.currentTime;
}, 50);
}, 50));

let lastBufferedEnd;
const progressInterval = setInterval(() => {
Expand All @@ -355,6 +375,40 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
this.dispatchEvent(new Event('progress'));
}
}, 100);
this.#timers.push(progressInterval);
}

#clearTimers() {
for (const id of this.#timers) clearInterval(id);
this.#timers = [];
}

connectedCallback() {
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
// `load()` is only triggered by attributeChangedCallback, so an element that
// is moved in the DOM (disconnected and reconnected without its attributes
// changing) has to ask for a new player itself.
if (this.#wasDisconnected) {
this.#wasDisconnected = false;
this.load();
}
super.connectedCallback?.();
}

disconnectedCallback() {
this.#wasDisconnected = true;
this.#loadId++;
this.#clearTimers();
this.#loadRequested = null;
this.#hasLoaded = null;
this.isLoaded = false;
this.loadComplete = new PublicPromise();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

disconnectedCallback replaces this.loadComplete with a new PublicPromise here, but it never settles the old one first.

If disconnect happens between building the player and onReady firing, anything still awaiting the old loadComplete, a play() or pause() call in flight, or user code awaiting it directly, hangs forever, since the destroyed player will never call resolve on that old reference.

Since PublicPromise already exposes .reject(), this should be a one line fix:

this.loadComplete.reject(new Error('disconnected before load completed'));
this.loadComplete = new PublicPromise();

Same gap exists in vimeo-video-element.js, so it is not new here, but worth closing now since this PR is the one making reconnect a supported, expected flow.

@jv8-alt jv8-alt Sep 22, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, fixed in db5a5e3. Two changes from the one-liner, both checked locally:

AbortError instead of Error. That's what HTMLMediaElement.play() rejects with when it's interrupted, and videojs-video-element already does the same with new DOMException(…, 'AbortError'). Callers that already ignore AbortError from play() then handle this case for free.

The reject by itself causes unhandled rejections. Three things are listening on loadComplete without a rejection handler:

  • load() awaits it itself (the pollers section), and load() is called without awaiting from attributeChangedCallback/connectedCallback
  • the currentTime, muted, playbackRate and volume setters chain .then() on it
  • if nothing is waiting at all, the rejected promise has no handler
    I tested it with a stubbed YT.Player: set the four properties and start a play(), then disconnect between construction and onReady. With the one-liner, play() settles but you get 5 unhandled rejections. With the fix, play() rejects with AbortError and there are 0. So the commit also marks the old promise handled before rejecting it (whoever is actually waiting still gets the rejection), catches the rejection inside load() so the #loadId check exits, and gives the four setters a no-op rejection handler, because after a disconnect that queued work doesn't matter any more.

One related spot, from before this PR: load() also replaces loadComplete without settling it when #hasLoaded is set, so changing src before the first onReady leaves the old promise pending too. I confirmed it with the same setup. It isn't part of the reconnect flow, so I've left it out here. Happy to add it, or send it with the vimeo-video-element fix you mentioned as a follow-up.

// The YouTube iframe API holds a reference to every player it creates, so
// dropping the element is not enough to release the <iframe>: it stays
// detached but reachable from window.YT. destroy() removes the iframe and
// deregisters the player.
this.api?.destroy?.();
this.api = null;
super.disconnectedCallback?.();
}

async attributeChangedCallback(attrName, oldValue, newValue) {
Expand Down