fix(track-button): properly remove event listeners on dispose to prevent leaks and runtime errors#9101
fix(track-button): properly remove event listeners on dispose to prevent leaks and runtime errors#9101nochev wants to merge 4 commits intovideojs:mainfrom
Conversation
|
💖 Thanks for opening this pull request! 💖 Things that will help get your PR across the finish line:
We get a lot of pull requests on this repo, so please be patient and we will get back to you as soon as we can. |
48ad645 to
c374ab7
Compare
Essk
left a comment
There was a problem hiding this comment.
Disclosure: This review was drafted with AI assistance (Claude Code) to help trace the dispose lifecycle and identify edge cases.
Thanks for this PR @nochev — the leak you identified is real and we've been wanting this fixed. I've spent some time testing and reviewing the approach and found a few issues that need addressing before this can merge. Happy to help work through them.
1. Double-dispose crash (this is what's failing CI)
Registering this.dispose as the player's 'dispose' handler causes TrackButton.dispose() to run twice during player teardown — once via the event, and again via the normal child disposal chain. The isDisposed_ guard in Component.dispose() would catch this, but your dispose() override does work before calling super.dispose(), so on the second call this.player_ is already null:
TypeError: Cannot read properties of null (reading 'off')
at AudioTrackButton.dispose
at ControlBar.dispose
at Player.dispose
The sequence is:
Player.dispose()→Component.dispose()fires'dispose'eventdisposeHandlerfires →TrackButton.dispose()→super.dispose()→ setsthis.player_ = null- Player continues disposing children → ControlBar → AudioTrackButton
TrackButton.dispose()runs again →this.player_.off(...)→ 💥
Fix: Use a separate cleanup method instead of wiring the full dispose() to the player's 'dispose' event.
2. Events.off(elem, type, null) removes ALL listeners
This is subtle. In video.js, calling removeEventListener(type, null) (or any falsy fn) hits this path in utils/events.js:
// If no listener was provided, remove all listeners for type
if (!fn) {
removeType(elem, type);
return;
}If you null out the handler references before the second cleanup call (which happens via the child disposal chain as described above), tracks.removeEventListener('removetrack', null) nukes every removetrack listener on the emulated TextTrackList — including the native-to-emulated proxy set up in Html5.proxyNativeTracksForType_(), and any listeners from other consumers. Since player.textTracks() delegates to tech.textTracks() (same object), this affects the entire track event pipeline.
Fix: Guard removeEventListener calls so they only run when the handler reference is still set.
3. Missing null guard on tracks in dispose (minor)
The constructor early-returns when !tracks, meaning updateHandler_ / disposeHandler_ are never created. But dispose() unconditionally accesses this.options_.tracks. In practice textTracks() / audioTracks() always return a TrackList, but for defensive consistency the guard should match the constructor.
Suggested implementation
Here's an approach that addresses all three issues — tested locally with full Safari and Chrome passes:
constructor(player, options) {
const tracks = options.tracks;
super(player, options);
if (this.items.length <= 1) {
this.hide();
}
if (!tracks) {
return;
}
this.updateHandler_ = Fn.bind_(this, this.update);
this.cleanupHandler_ = Fn.bind_(this, this.cleanupTrackListeners_);
tracks.addEventListener('removetrack', this.updateHandler_);
tracks.addEventListener('addtrack', this.updateHandler_);
tracks.addEventListener('labelchange', this.updateHandler_);
this.player_.on('ready', this.updateHandler_);
this.player_.on('dispose', this.cleanupHandler_);
}
/**
* Remove track list event listeners.
*
* @private
*/
cleanupTrackListeners_() {
const tracks = this.options_.tracks;
if (tracks && this.updateHandler_) {
tracks.removeEventListener('removetrack', this.updateHandler_);
tracks.removeEventListener('addtrack', this.updateHandler_);
tracks.removeEventListener('labelchange', this.updateHandler_);
}
if (this.player_) {
if (this.updateHandler_) {
this.player_.off('ready', this.updateHandler_);
}
if (this.cleanupHandler_) {
this.player_.off('dispose', this.cleanupHandler_);
}
}
this.updateHandler_ = null;
this.cleanupHandler_ = null;
}
/**
* Dispose of the Component and remove all event listeners.
*
* @override
*/
dispose() {
this.cleanupTrackListeners_();
super.dispose();
}The key ideas:
- Separate cleanup method — the player's
'dispose'handler only cleans up listeners, it doesn't trigger a full component disposal - Null guards on handler references — prevents the
Events.offnuke-all-listeners footgun = nullinstead ofdelete— matches the convention inComponent.dispose()
Happy to answer any questions on any of this. The original diagnosis of the leak was spot on — these are just implementation details to make it safe in video.js's dispose lifecycle.
Description
This PR fixes a memory leak issue in the
TrackButtoncomponent.Currently,
TrackButtonattaches event listeners toTextTrackListand the player instance, but only removes some of them when the player is disposed. If aTrackButtoninstance itself is disposed (e.g. when custom text tracks are removed/added dynamically), the listeners remain active, causing stale references and potential leaks.The issue was noticed when dynamically removing and adding text tracks in
CaptionsButtonorTrackButtoncomponents — old listeners were not cleaned up, leading to unexpected behavior, memory usage growth and errors likemain.js:1685 VIDEOJS: ERROR: Error: Invalid target for null#on; must be a DOM node or evented object..Specific Changes proposed
updateHandleranddisposeHandleras private properties (this.updateHandler_,this.disposeHandler_).dispose()to:TextTrackListand the player.delete this.updateHandler_,delete this.disposeHandler_).super.dispose()afterwards.Requirements Checklist
dispose())