Skip to content

Commit 4708538

Browse files
fix: Patch Memory Leaks (#218)
* Fixed leaks for custom-media-element and media-tracks * Added leak fixes for castable-video * Add null guards for renditionList in addRendition and removeRendition * Restructured connectedCallback for custom media element --------- Co-authored-by: Santiago Puppo <spuppo@mux.com>
1 parent 3149cd4 commit 4708538

File tree

8 files changed

+109
-36
lines changed

8 files changed

+109
-36
lines changed

packages/castable-video/castable-mixin.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export const CastableMediaMixin = (superclass) =>
3939
if (this.#remote) return this.#remote;
4040

4141
if (requiresCastFramework()) {
42+
// Don't create a new RemotePlayback when the element is disconnected.
43+
// super.disconnectedCallback() may trigger property access that reaches
44+
// this getter, which would re-create a RemotePlayback after cleanup.
45+
if (!this.isConnected) return undefined;
46+
4247
// No need to load the Cast framework if it's disabled.
4348
if (!this.disableRemotePlayback) {
4449
loadCastFramework();
@@ -58,6 +63,13 @@ export const CastableMediaMixin = (superclass) =>
5863
return privateProps.get(this.remote)?.getCastPlayer?.();
5964
}
6065

66+
disconnectedCallback() {
67+
this.#remote?.destroy();
68+
this.#remote = null;
69+
privateProps.delete(this);
70+
super.disconnectedCallback?.();
71+
}
72+
6173
attributeChangedCallback(attrName, oldValue, newValue) {
6274
super.attributeChangedCallback(attrName, oldValue, newValue);
6375

packages/castable-video/castable-remote-playback.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export class RemotePlayback extends EventTarget {
5656
#available = false;
5757
#callbacks = new Set();
5858
#callbackIds = new WeakMap();
59+
#onTextTrackChange = () => this.#updateRemoteTextTrack();
5960

6061
constructor(media) {
6162
super();
@@ -73,6 +74,21 @@ export class RemotePlayback extends EventTarget {
7374
this.#init();
7475
}
7576

77+
destroy() {
78+
this.#media?.textTracks?.removeEventListener('change', this.#onTextTrackChange);
79+
80+
// Remove controller listeners if element is removed while casting
81+
// (in that case #disconnect() won't be called).
82+
if (this.#remoteListeners && this.#remotePlayer?.controller) {
83+
Object.entries(this.#remoteListeners).forEach(([event, listener]) => {
84+
this.#remotePlayer.controller.removeEventListener(event, listener);
85+
});
86+
}
87+
88+
if (this.#media) castElementRef.delete(this.#media);
89+
this.#isInit = false;
90+
}
91+
7692
get #castPlayer() {
7793
if (castElementRef.has(this.#media)) return this.#remotePlayer;
7894
return undefined;
@@ -248,7 +264,7 @@ export class RemotePlayback extends EventTarget {
248264
* loaded manually in the load() method. This will require a new load() call
249265
* for each added/removed track w/ src.
250266
*/
251-
this.#media.textTracks.addEventListener('change', () => this.#updateRemoteTextTrack());
267+
this.#media.textTracks.addEventListener('change', this.#onTextTrackChange);
252268

253269
this.#onCastStateChanged();
254270

packages/custom-media-element/custom-media-element.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(superclass:
267267
#nativeEl: HTMLVideoElement | HTMLAudioElement | null = null;
268268
#childMap = new Map<MediaChild, MediaChild>();
269269
#childObserver?: MutationObserver;
270+
#slotChangeHandler?: () => void;
270271

271272
get: ((prop: string) => any) | undefined;
272273
set: ((prop: string, val: any) => void) | undefined;
@@ -342,12 +343,17 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(superclass:
342343
this.#upgradeProperty(prop);
343344
}
344345

346+
this.#setupListeners();
347+
}
348+
349+
#setupListeners(): void {
345350
this.#childObserver = new MutationObserver(this.#syncMediaChildAttribute.bind(this));
346-
this.shadowRoot!.addEventListener('slotchange', () => this.#syncMediaChildren());
351+
this.#slotChangeHandler = () => this.#syncMediaChildren();
352+
this.shadowRoot?.addEventListener('slotchange', this.#slotChangeHandler);
347353
this.#syncMediaChildren();
348354

349355
for (const type of (this.constructor as typeof CustomMedia).Events) {
350-
this.shadowRoot!.addEventListener(type, this, true);
356+
this.shadowRoot?.addEventListener(type, this, true);
351357
}
352358
}
353359

@@ -446,7 +452,36 @@ export function CustomMediaMixin<T extends Constructor<HTMLElement>>(superclass:
446452
}
447453

448454
connectedCallback(): void {
455+
// this.#init will check this.#isInit
449456
this.#init();
457+
// Re-mount: re-setup listeners cleaned up in disconnectedCallback.
458+
// This is also done in this.init()
459+
if (!this.#slotChangeHandler) {
460+
this.#setupListeners();
461+
}
462+
}
463+
464+
disconnectedCallback(): void {
465+
this.#childObserver?.disconnect();
466+
this.#childObserver = undefined;
467+
468+
if (this.#slotChangeHandler) {
469+
this.shadowRoot?.removeEventListener('slotchange', this.#slotChangeHandler);
470+
this.#slotChangeHandler = undefined;
471+
}
472+
473+
for (const type of (this.constructor as typeof CustomMedia).Events) {
474+
this.shadowRoot?.removeEventListener(type, this, true);
475+
}
476+
477+
this.#childMap.forEach((clone) => clone.remove());
478+
this.#childMap.clear();
479+
480+
this.#nativeEl = null;
481+
// Keep #isInit = true so that #init() is a no-op during the rest of
482+
// the disconnect process. Other mixins in the chain may access properties
483+
// (e.g. this.nativeEl, this.currentTime) which trigger #init() → init()
484+
// and re-create the closures we just cleaned up.
450485
}
451486
};
452487
}

packages/media-tracks/src/audio-rendition-list.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { RenditionEvent } from './rendition-event.js';
44
import { getPrivate } from './utils.js';
55

66
export function addRendition(track: AudioTrack, rendition: AudioRendition) {
7-
const renditionList = getPrivate(track).media.audioRenditions;
7+
const renditionList = getPrivate(track).media?.deref()?.audioRenditions;
88

99
getPrivate(rendition).media = getPrivate(track).media;
1010
getPrivate(rendition).track = track;
@@ -20,28 +20,28 @@ export function addRendition(track: AudioTrack, rendition: AudioRendition) {
2020
}
2121

2222
queueMicrotask(() => {
23-
if (!track.enabled) return;
23+
if (!renditionList || !track.enabled) return;
2424

2525
renditionList.dispatchEvent(new RenditionEvent('addrendition', { rendition }));
2626
});
2727
}
2828

2929
export function removeRendition(rendition: AudioRendition) {
30-
const renditionList: AudioRenditionList = getPrivate(rendition).media.audioRenditions;
30+
const renditionList: AudioRenditionList = getPrivate(rendition).media?.deref()?.audioRenditions;
3131
const track: AudioTrack = getPrivate(rendition).track;
3232
const renditionSet: Set<AudioRendition> = getPrivate(track).renditionSet;
3333
renditionSet.delete(rendition);
3434

3535
queueMicrotask(() => {
3636
const track: AudioTrack = getPrivate(rendition).track;
37-
if (!track.enabled) return;
37+
if (!renditionList || !track.enabled) return;
3838

3939
renditionList.dispatchEvent(new RenditionEvent('removerendition', { rendition }));
4040
});
4141
}
4242

4343
export function selectedChanged(rendition: AudioRendition) {
44-
const renditionList: AudioRenditionList = getPrivate(rendition).media.audioRenditions;
44+
const renditionList: AudioRenditionList = getPrivate(rendition).media?.deref()?.audioRenditions;
4545

4646
// Prevent firing a rendition list `change` event multiple times per tick.
4747
if (!renditionList || getPrivate(renditionList).changeRequested) return;
@@ -58,7 +58,8 @@ export function selectedChanged(rendition: AudioRendition) {
5858
}
5959

6060
function getCurrentRenditions(renditionList: AudioRenditionList): AudioRendition[] {
61-
const media: HTMLMediaElement = getPrivate(renditionList).media;
61+
const media: HTMLMediaElement = getPrivate(renditionList).media?.deref();
62+
if (!media) return [];
6263
return [...media.audioTracks]
6364
.filter((track: AudioTrack) => track.enabled)
6465
.flatMap((track: AudioTrack) => [...getPrivate(track).renditionSet]);

packages/media-tracks/src/audio-track-list.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getPrivate } from './utils.js';
44

55
export function addAudioTrack(media: HTMLMediaElement, track: AudioTrack) {
66
const trackList = media.audioTracks;
7-
getPrivate(track).media = media;
7+
getPrivate(track).media = new WeakRef(media);
88

99
if (!getPrivate(track).renditionSet) {
1010
getPrivate(track).renditionSet = new Set();
@@ -31,7 +31,7 @@ export function addAudioTrack(media: HTMLMediaElement, track: AudioTrack) {
3131
}
3232

3333
export function removeAudioTrack(track: AudioTrack) {
34-
const trackList: AudioTrackList = getPrivate(track).media?.audioTracks;
34+
const trackList: AudioTrackList = getPrivate(track).media?.deref()?.audioTracks;
3535
if (!trackList) return;
3636

3737
const trackSet: Set<AudioTrack> = getPrivate(trackList).trackSet;
@@ -47,7 +47,7 @@ export function enabledChanged(track: AudioTrack) {
4747
// and whenever one that was enabled is disabled, the user agent must queue a
4848
// media element task given the media element to fire an event named `change`
4949
// at the AudioTrackList object.
50-
const trackList: AudioTrackList = getPrivate(track).media.audioTracks;
50+
const trackList: AudioTrackList = getPrivate(track).media?.deref()?.audioTracks;
5151

5252
// Prevent firing a track list `change` event multiple times per tick.
5353
if (!trackList || getPrivate(trackList).changeRequested) return;

packages/media-tracks/src/mixin.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function MediaTracksMixin<T>(MediaElementClass: T): WithMediaTracks<T> {
123123
let renditions = getPrivate(media).videoRenditions;
124124
if (!renditions) {
125125
renditions = new VideoRenditionList();
126-
getPrivate(renditions).media = media;
126+
getPrivate(renditions).media = new WeakRef(media);
127127
getPrivate(media).videoRenditions = renditions;
128128
}
129129
return renditions;
@@ -141,7 +141,7 @@ export function MediaTracksMixin<T>(MediaElementClass: T): WithMediaTracks<T> {
141141
let renditions = getPrivate(media).audioRenditions;
142142
if (!renditions) {
143143
renditions = new AudioRenditionList();
144-
getPrivate(renditions).media = media;
144+
getPrivate(renditions).media = new WeakRef(media);
145145
getPrivate(media).audioRenditions = renditions;
146146
}
147147
return renditions;
@@ -171,11 +171,11 @@ function getVideoTracks(media: any) {
171171
addVideoTrack(media, nativeTrack);
172172
}
173173

174-
nativeTracks.addEventListener('change', () => {
174+
const onChange = () => {
175175
tracks.dispatchEvent(new Event('change'));
176-
});
176+
};
177177

178-
nativeTracks.addEventListener('addtrack', (event: TrackEvent) => {
178+
const onAddTrack = (event: TrackEvent) => {
179179
// Note: adding native track instances to the shim track list here.
180180
// This works because the API is identical and change event is forwarded.
181181
// If tracks were manually added prevent native tracks from being added...
@@ -189,11 +189,15 @@ function getVideoTracks(media: any) {
189189
}
190190

191191
addVideoTrack(media, event.track as VideoTrack);
192-
});
192+
};
193193

194-
nativeTracks.addEventListener('removetrack', (event: TrackEvent) => {
195-
removeVideoTrack(event.track as VideoTrack);
196-
});
194+
const onRemoveTrack = (event: TrackEvent) => {
195+
removeVideoTrack(event.track as VideoTrack);
196+
};
197+
198+
nativeTracks.addEventListener('change', onChange);
199+
nativeTracks.addEventListener('addtrack', onAddTrack);
200+
nativeTracks.addEventListener('removetrack', onRemoveTrack);
197201
}
198202
}
199203
return tracks;
@@ -214,11 +218,11 @@ function getAudioTracks(media: any) {
214218
addAudioTrack(media, nativeTrack);
215219
}
216220

217-
nativeTracks.addEventListener('change', () => {
221+
const onChange = () => {
218222
tracks.dispatchEvent(new Event('change'));
219-
});
223+
};
220224

221-
nativeTracks.addEventListener('addtrack', (event: TrackEvent) => {
225+
const onAddTrack = (event: TrackEvent) => {
222226
// Note: adding native track instances to the shim track list here.
223227
// This works because the API is identical and change event is forwarded.
224228
// If tracks were manually added prevent native tracks from being added...
@@ -232,11 +236,15 @@ function getAudioTracks(media: any) {
232236
}
233237

234238
addAudioTrack(media, event.track as AudioTrack);
235-
});
239+
};
236240

237-
nativeTracks.addEventListener('removetrack', (event: TrackEvent) => {
241+
const onRemoveTrack = (event: TrackEvent) => {
238242
removeAudioTrack(event.track as AudioTrack);
239-
});
243+
};
244+
245+
nativeTracks.addEventListener('change', onChange);
246+
nativeTracks.addEventListener('addtrack', onAddTrack);
247+
nativeTracks.addEventListener('removetrack', onRemoveTrack);
240248
}
241249
}
242250
return tracks;

packages/media-tracks/src/video-rendition-list.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { RenditionEvent } from './rendition-event.js';
44
import { getPrivate } from './utils.js';
55

66
export function addRendition(track: VideoTrack, rendition: VideoRendition) {
7-
const renditionList = getPrivate(track).media.videoRenditions;
7+
const renditionList = getPrivate(track).media?.deref()?.videoRenditions;
88

99
getPrivate(rendition).media = getPrivate(track).media;
1010
getPrivate(rendition).track = track;
@@ -20,28 +20,28 @@ export function addRendition(track: VideoTrack, rendition: VideoRendition) {
2020
}
2121

2222
queueMicrotask(() => {
23-
if (!track.selected) return;
23+
if (!renditionList || !track.selected) return;
2424

2525
renditionList.dispatchEvent(new RenditionEvent('addrendition', { rendition }));
2626
});
2727
}
2828

2929
export function removeRendition(rendition: VideoRendition) {
30-
const renditionList: VideoRenditionList = getPrivate(rendition).media.videoRenditions;
30+
const renditionList: VideoRenditionList = getPrivate(rendition).media?.deref()?.videoRenditions;
3131
const track: VideoTrack = getPrivate(rendition).track;
3232
const renditionSet: Set<VideoRendition> = getPrivate(track).renditionSet;
3333
renditionSet.delete(rendition);
3434

3535
queueMicrotask(() => {
3636
const track: VideoTrack = getPrivate(rendition).track;
37-
if (!track.selected) return;
37+
if (!renditionList || !track.selected) return;
3838

3939
renditionList.dispatchEvent(new RenditionEvent('removerendition', { rendition }));
4040
});
4141
}
4242

4343
export function selectedChanged(rendition: VideoRendition) {
44-
const renditionList: VideoRenditionList = getPrivate(rendition).media.videoRenditions;
44+
const renditionList: VideoRenditionList = getPrivate(rendition).media?.deref()?.videoRenditions;
4545

4646
// Prevent firing a rendition list `change` event multiple times per tick.
4747
if (!renditionList || getPrivate(renditionList).changeRequested) return;
@@ -58,7 +58,8 @@ export function selectedChanged(rendition: VideoRendition) {
5858
}
5959

6060
function getCurrentRenditions(renditionList: VideoRenditionList): VideoRendition[] {
61-
const media: HTMLMediaElement = getPrivate(renditionList).media;
61+
const media: HTMLMediaElement = getPrivate(renditionList).media?.deref();
62+
if (!media) return [];
6263
return [...media.videoTracks]
6364
.filter((track: VideoTrack) => track.selected)
6465
.flatMap((track: VideoTrack) => [...getPrivate(track).renditionSet]);

packages/media-tracks/src/video-track-list.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getPrivate } from './utils.js';
44

55
export function addVideoTrack(media: HTMLMediaElement, track: VideoTrack) {
66
const trackList = media.videoTracks;
7-
getPrivate(track).media = media;
7+
getPrivate(track).media = new WeakRef(media);
88

99
if (!getPrivate(track).renditionSet) {
1010
getPrivate(track).renditionSet = new Set();
@@ -31,7 +31,7 @@ export function addVideoTrack(media: HTMLMediaElement, track: VideoTrack) {
3131
}
3232

3333
export function removeVideoTrack(track: VideoTrack) {
34-
const trackList: VideoTrackList = getPrivate(track).media?.videoTracks;
34+
const trackList: VideoTrackList = getPrivate(track).media?.deref()?.videoTracks;
3535
if (!trackList) return;
3636

3737
const trackSet: Set<VideoTrack> = getPrivate(trackList).trackSet;
@@ -43,7 +43,7 @@ export function removeVideoTrack(track: VideoTrack) {
4343
}
4444

4545
export function selectedChanged(selected: VideoTrack) {
46-
const trackList: VideoTrackList = getPrivate(selected).media.videoTracks ?? [];
46+
const trackList: VideoTrackList = getPrivate(selected).media?.deref()?.videoTracks ?? [];
4747
// If other tracks are unselected, then a change event will be fired.
4848
let hasUnselected = false;
4949

0 commit comments

Comments
 (0)