From c5a90232df1b8f1ac40b5c016b0932c09724c277 Mon Sep 17 00:00:00 2001 From: Rahim Date: Sun, 24 May 2026 21:22:43 -0700 Subject: [PATCH] fix: guard stale hls instance callbacks --- .../vidstack/src/providers/hls/hls.test.ts | 149 ++++++++++++++++++ packages/vidstack/src/providers/hls/hls.ts | 69 +++++--- 2 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 packages/vidstack/src/providers/hls/hls.test.ts diff --git a/packages/vidstack/src/providers/hls/hls.test.ts b/packages/vidstack/src/providers/hls/hls.test.ts new file mode 100644 index 000000000..cb11ac420 --- /dev/null +++ b/packages/vidstack/src/providers/hls/hls.test.ts @@ -0,0 +1,149 @@ +import type * as HLS from 'hls.js'; +import { vi } from 'vitest'; + +import { QualitySymbol } from '../../core/quality/symbols'; +import { ListSymbol } from '../../foundation/list/symbols'; +import { HLSController } from './hls'; +import type { HLSConstructor } from './types'; + +const Events = { + AUDIO_TRACK_SWITCHED: 'AUDIO_TRACK_SWITCHED', + CUES_PARSED: 'CUES_PARSED', + ERROR: 'ERROR', + LEVEL_LOADED: 'LEVEL_LOADED', + LEVEL_SWITCHED: 'LEVEL_SWITCHED', + LEVEL_UPDATED: 'LEVEL_UPDATED', + NON_NATIVE_TEXT_TRACKS_FOUND: 'NON_NATIVE_TEXT_TRACKS_FOUND', +} as const; + +class FakeHLS { + static Events = Events; + + audioTracks: any[] = []; + currentLevel = -1; + levels: any[] = []; + liveSyncPosition: number | null = null; + media: HTMLMediaElement | null = null; + subtitleTrack = -1; + + readonly #listeners = new Map void>>(); + + constructor(_config: Partial) {} + + on(event: string, callback: (eventType: string, data: any) => void) { + let listeners = this.#listeners.get(event); + if (!listeners) this.#listeners.set(event, (listeners = new Set())); + listeners.add(callback); + } + + attachMedia(media: HTMLMediaElement) { + this.media = media; + } + + destroy() { + this.media = null; + } + + emit(event: string, data: any) { + for (const listener of this.#listeners.get(event) ?? []) { + listener(event, data); + } + } + + loadSource() {} + + recoverMediaError() {} +} + +it('ignores stale level loaded callbacks after destroy', () => { + const { controller, ctx, video } = setup(); + const instance = controller.instance as unknown as FakeHLS; + const onCanPlay = vi.fn(); + + video.addEventListener('canplay', onCanPlay); + ctx.player.dispatch.mockClear(); + controller.destroy(); + + expect(() => instance.emit(Events.LEVEL_LOADED, createLevelLoadedData())).not.toThrow(); + + expect(ctx.player.dispatch).not.toHaveBeenCalled(); + expect(ctx.notify).not.toHaveBeenCalled(); + expect(ctx.audioTracks[ListSymbol.add]).not.toHaveBeenCalled(); + expect(ctx.qualities[ListSymbol.add]).not.toHaveBeenCalled(); + expect(ctx.qualities[QualitySymbol.setAuto]).not.toHaveBeenCalled(); + expect(onCanPlay).not.toHaveBeenCalled(); +}); + +it('returns from level loaded callbacks when media is unavailable', () => { + const { controller, ctx, video } = setup(); + const instance = controller.instance as unknown as FakeHLS; + const onCanPlay = vi.fn(); + + video.addEventListener('canplay', onCanPlay); + instance.media = null; + ctx.player.dispatch.mockClear(); + + expect(() => instance.emit(Events.LEVEL_LOADED, createLevelLoadedData())).not.toThrow(); + + expect(ctx.notify).not.toHaveBeenCalled(); + expect(ctx.audioTracks[ListSymbol.add]).not.toHaveBeenCalled(); + expect(ctx.qualities[ListSymbol.add]).not.toHaveBeenCalled(); + expect(ctx.qualities[QualitySymbol.setAuto]).not.toHaveBeenCalled(); + expect(onCanPlay).not.toHaveBeenCalled(); +}); + +function setup() { + const video = document.createElement('video'), + ctx = createContext(), + controller = new HLSController(video, ctx); + + controller.setup(FakeHLS as unknown as HLSConstructor); + + return { controller, ctx, video }; +} + +function createContext() { + const audioTracks = new EventTarget() as any, + qualities = new EventTarget() as any; + + audioTracks.selectedIndex = -1; + audioTracks[ListSymbol.add] = vi.fn(); + audioTracks[ListSymbol.select] = vi.fn(); + + qualities.auto = false; + qualities.selectedIndex = -1; + qualities.switch = 'current'; + qualities[ListSymbol.add] = vi.fn(); + qualities[ListSymbol.select] = vi.fn(); + qualities[QualitySymbol.setAuto] = vi.fn(); + + return { + $state: { + canPlay: () => false, + inferredLiveDVRWindow: { set: vi.fn() }, + live: () => false, + liveSyncPosition: { set: vi.fn() }, + source: () => null, + streamType: () => 'on-demand', + }, + audioTracks, + notify: vi.fn(), + player: { dispatch: vi.fn() }, + qualities, + textTracks: { + add: vi.fn(), + getById: vi.fn(), + }, + } as any; +} + +function createLevelLoadedData() { + return { + details: { + live: false, + targetduration: 6, + totalduration: 30, + type: 'VOD', + }, + } as HLS.LevelLoadedData; +} diff --git a/packages/vidstack/src/providers/hls/hls.ts b/packages/vidstack/src/providers/hls/hls.ts index 27025bb6b..8ed5e8e93 100644 --- a/packages/vidstack/src/providers/hls/hls.ts +++ b/packages/vidstack/src/providers/hls/hls.ts @@ -38,30 +38,46 @@ export class HLSController { const isLive = peek(streamType).includes('live'), isLiveLowLatency = peek(streamType).includes('ll-'); - this.#instance = new ctor({ + const instance = (this.#instance = new ctor({ lowLatencyMode: isLiveLowLatency, backBufferLength: isLiveLowLatency ? 4 : isLive ? 8 : undefined, renderTextTracksNatively: false, ...this.config, - }); + })); + + const guard = + (callback: (eventType: string, data: Data) => void) => + (eventType: string, data: Data) => { + if (this.#instance !== instance) return; + callback(eventType, data); + }; - const dispatcher = this.#dispatchHLSEvent.bind(this); - for (const event of Object.values(ctor.Events)) this.#instance.on(event, dispatcher); + const dispatcher = guard(this.#dispatchHLSEvent.bind(this)); + for (const event of Object.values(ctor.Events)) instance.on(event, dispatcher); - this.#instance.on(ctor.Events.ERROR, this.#onError.bind(this)); - for (const callback of this.#callbacks) callback(this.#instance); + instance.on(ctor.Events.ERROR, guard(this.#onError.bind(this))); + for (const callback of this.#callbacks) callback(instance); this.#ctx.player.dispatch('hls-instance', { - detail: this.#instance, + detail: instance, }); - this.#instance.attachMedia(this.#video); - this.#instance.on(ctor.Events.AUDIO_TRACK_SWITCHED, this.#onAudioSwitch.bind(this)); - this.#instance.on(ctor.Events.LEVEL_SWITCHED, this.#onLevelSwitched.bind(this)); - this.#instance.on(ctor.Events.LEVEL_LOADED, this.#onLevelLoaded.bind(this)); - this.#instance.on(ctor.Events.LEVEL_UPDATED, this.#onLevelUpdated.bind(this)); - this.#instance.on(ctor.Events.NON_NATIVE_TEXT_TRACKS_FOUND, this.#onTracksFound.bind(this)); - this.#instance.on(ctor.Events.CUES_PARSED, this.#onCuesParsed.bind(this)); + instance.attachMedia(this.#video); + instance.on(ctor.Events.AUDIO_TRACK_SWITCHED, guard(this.#onAudioSwitch.bind(this))); + instance.on(ctor.Events.LEVEL_SWITCHED, guard(this.#onLevelSwitched.bind(this))); + instance.on( + ctor.Events.LEVEL_LOADED, + guard(this.#onLevelLoaded.bind(this, instance)), + ); + instance.on(ctor.Events.LEVEL_UPDATED, guard(this.#onLevelUpdated.bind(this))); + instance.on( + ctor.Events.NON_NATIVE_TEXT_TRACKS_FOUND, + guard(this.#onTracksFound.bind(this, instance)), + ); + instance.on( + ctor.Events.CUES_PARSED, + guard(this.#onCuesParsed.bind(this, instance)), + ); this.#ctx.qualities[QualitySymbol.enableAuto] = this.#enableAutoQuality.bind(this); @@ -90,7 +106,7 @@ export class HLSController { this.#ctx.player?.dispatch(this.#createDOMEvent(type, data)); } - #onTracksFound(eventType: string, data: HLS.NonNativeTextTracksData) { + #onTracksFound(instance: HLS.default, eventType: string, data: HLS.NonNativeTextTracksData) { const event = this.#createDOMEvent(eventType, data); let currentTrack = -1; @@ -109,11 +125,13 @@ export class HLSController { track[TextTrackSymbol.readyState] = 2; track[TextTrackSymbol.onModeChange] = () => { + if (this.#instance !== instance) return; + if (track.mode === 'showing') { - this.#instance!.subtitleTrack = i; + instance.subtitleTrack = i; currentTrack = i; } else if (currentTrack === i) { - this.#instance!.subtitleTrack = -1; + instance.subtitleTrack = -1; currentTrack = -1; } }; @@ -122,8 +140,8 @@ export class HLSController { } } - #onCuesParsed(eventType: string, data: HLS.CuesParsedData) { - const index = this.#instance?.subtitleTrack, + #onCuesParsed(instance: HLS.default, eventType: string, data: HLS.CuesParsedData) { + const index = instance.subtitleTrack, track = this.#ctx.textTracks.getById(`hls-${data.type}-${index}`); if (!track) return; @@ -158,9 +176,12 @@ export class HLSController { } } - #onLevelLoaded(eventType: string, data: HLS.LevelLoadedData): void { + #onLevelLoaded(instance: HLS.default, eventType: string, data: HLS.LevelLoadedData): void { if (this.#ctx.$state.canPlay()) return; + const media = instance.media; + if (!media) return; + const { type, live, totalduration: duration, targetduration } = data.details, trigger = this.#createDOMEvent(eventType, data); @@ -176,13 +197,11 @@ export class HLSController { this.#ctx.notify('duration-change', duration, trigger); - const media = this.#instance!.media!; - - if (this.#instance!.currentLevel === -1) { + if (instance.currentLevel === -1) { this.#ctx.qualities[QualitySymbol.setAuto](true, trigger); } - for (const remoteTrack of this.#instance!.audioTracks) { + for (const remoteTrack of instance.audioTracks) { const localTrack = { id: remoteTrack.id.toString(), label: remoteTrack.name, @@ -193,7 +212,7 @@ export class HLSController { this.#ctx.audioTracks[ListSymbol.add](localTrack, trigger); } - for (const level of this.#instance!.levels) { + for (const level of instance.levels) { const videoQuality = { id: level.id?.toString() ?? level.height + 'p', width: level.width,