Skip to content

Commit b3f6dff

Browse files
authored
fix(vidstack): provider load connect race (#1840)
1 parent 1e88631 commit b3f6dff

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import {
2+
createComponent,
3+
createScope,
4+
provideContext,
5+
root,
6+
signal,
7+
type Scope,
8+
} from 'maverick.js';
9+
import { vi } from 'vitest';
10+
11+
import { mediaContext, type MediaContext } from '../../core/api/media-context';
12+
import type { Src } from '../../core/api/src-types';
13+
import type { MediaProviderAdapter, MediaProviderLoader } from '../../providers/types';
14+
import { MediaProvider } from './provider';
15+
16+
beforeEach(() => {
17+
const raf = vi.fn((callback: FrameRequestCallback) => {
18+
callback(0);
19+
return 1;
20+
}),
21+
caf = vi.fn();
22+
23+
vi.stubGlobal('requestAnimationFrame', raf);
24+
vi.stubGlobal('cancelAnimationFrame', caf);
25+
vi.stubGlobal(
26+
'ResizeObserver',
27+
class ResizeObserver {
28+
observe = vi.fn();
29+
disconnect = vi.fn();
30+
},
31+
);
32+
33+
window.requestAnimationFrame = raf;
34+
window.cancelAnimationFrame = caf;
35+
});
36+
37+
afterEach(() => {
38+
vi.unstubAllGlobals();
39+
});
40+
41+
it('defers loading the provider target until connected', async () => {
42+
const src: Src = { src: 'https://example.com/audio.custom', type: 'application/custom' },
43+
events: string[] = [],
44+
adapter = createAdapter(),
45+
loader = createLoader(adapter),
46+
media = createMediaContext(src, events);
47+
48+
adapter.setup.mockImplementation(() => {
49+
media.notify('provider-setup', adapter);
50+
});
51+
52+
let dispose!: () => void;
53+
const host = document.createElement('div'),
54+
target = document.createElement('audio'),
55+
provider = root((disposer) => {
56+
dispose = disposer;
57+
provideContext(mediaContext, media);
58+
return createComponent(MediaProvider, { props: { loaders: [loader] } });
59+
});
60+
61+
provider.$$.setup();
62+
provider.$$.attach(host);
63+
provider.load(target);
64+
65+
expect(target.getAttribute('aria-hidden')).to.equal('true');
66+
expect(loader.load).not.toHaveBeenCalled();
67+
expect(events).not.toContain('provider-change');
68+
69+
provider.$$.connect();
70+
71+
await vi.waitFor(() => {
72+
expect(loader.load).toHaveBeenCalledOnce();
73+
expect(adapter.setup).toHaveBeenCalledOnce();
74+
expect(adapter.loadSource).toHaveBeenCalledWith(src, 'metadata');
75+
});
76+
77+
expect(events).toContain('provider-setup');
78+
79+
dispose();
80+
adapter.scope.dispose();
81+
});
82+
83+
function createAdapter(): MediaProviderAdapter & {
84+
scope: Scope;
85+
setup: ReturnType<typeof vi.fn>;
86+
loadSource: ReturnType<typeof vi.fn>;
87+
} {
88+
let currentSrc: Src | null = null;
89+
90+
const adapter = {
91+
scope: createScope(),
92+
type: 'audio',
93+
get currentSrc() {
94+
return currentSrc;
95+
},
96+
setup: vi.fn(),
97+
destroy: vi.fn(),
98+
play: vi.fn(() => Promise.resolve()),
99+
pause: vi.fn(),
100+
setMuted: vi.fn(),
101+
setCurrentTime: vi.fn(),
102+
setVolume: vi.fn(),
103+
loadSource: vi.fn((src: Src) => {
104+
currentSrc = src;
105+
return Promise.resolve();
106+
}),
107+
} as unknown as MediaProviderAdapter & {
108+
scope: Scope;
109+
setup: ReturnType<typeof vi.fn>;
110+
loadSource: ReturnType<typeof vi.fn>;
111+
};
112+
113+
return adapter;
114+
}
115+
116+
function createLoader(adapter: MediaProviderAdapter): MediaProviderLoader {
117+
return {
118+
name: 'custom-audio',
119+
target: null,
120+
canPlay: vi.fn((src: Src) => src.type === 'application/custom'),
121+
mediaType: vi.fn(() => 'audio'),
122+
preconnect: vi.fn(),
123+
load: vi.fn(async () => adapter),
124+
};
125+
}
126+
127+
function createMediaContext(src: Src, events: string[]): MediaContext {
128+
const state = {
129+
canLoad: signal(true),
130+
canLoadPoster: signal(false),
131+
crossOrigin: signal(null),
132+
currentTime: signal(0),
133+
inferredViewType: signal('unknown'),
134+
mediaType: signal('unknown'),
135+
paused: signal(true),
136+
poster: signal(''),
137+
preload: signal('metadata'),
138+
providedPoster: signal(false),
139+
quality: signal(null),
140+
remotePlaybackLoader: signal(null),
141+
savedState: signal(null),
142+
source: signal({ src: '', type: '' } as Src),
143+
sources: signal([] as Src[]),
144+
started: signal(false),
145+
};
146+
147+
const media = {
148+
$provider: signal(null),
149+
$providerSetup: signal(false),
150+
$props: {
151+
preferNativeHLS: signal(false),
152+
src: signal(src),
153+
},
154+
$state: state,
155+
audioTracks: [],
156+
notify(type: string, detail: any) {
157+
events.push(type);
158+
159+
switch (type) {
160+
case 'provider-change':
161+
media.$provider.set(detail);
162+
break;
163+
case 'sources-change':
164+
state.sources.set(detail);
165+
break;
166+
case 'source-change':
167+
state.source.set(detail);
168+
break;
169+
case 'media-type-change':
170+
state.mediaType.set(detail);
171+
break;
172+
}
173+
},
174+
player: null,
175+
qualities: [],
176+
storage: null,
177+
textTracks: {
178+
add: vi.fn(),
179+
getById: vi.fn(() => null),
180+
remove: vi.fn(),
181+
},
182+
} as unknown as MediaContext;
183+
184+
return media;
185+
}

packages/vidstack/src/components/provider/provider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export class MediaProvider extends Component<MediaProviderProps, MediaProviderSt
3838
#domTracks = signal<TextTrackInit[]>([]);
3939

4040
#loader: MediaProviderLoader | null = null;
41+
#target: HTMLElement | null = null;
42+
#connected = false;
4143

4244
protected override onSetup() {
4345
this.#media = useMediaContext();
@@ -54,6 +56,7 @@ export class MediaProvider extends Component<MediaProviderProps, MediaProviderSt
5456
}
5557

5658
protected override onConnect(el: HTMLElement) {
59+
this.#connected = true;
5760
this.#sources.connect();
5861
new Tracks(this.#domTracks, this.#media);
5962

@@ -65,8 +68,10 @@ export class MediaProvider extends Component<MediaProviderProps, MediaProviderSt
6568

6669
this.#onResize();
6770
this.#onMutation();
71+
if (this.#target) this.load(this.#target);
6872

6973
onDispose(() => {
74+
this.#connected = false;
7075
resize.disconnect();
7176
mutations.disconnect();
7277
});
@@ -76,9 +81,13 @@ export class MediaProvider extends Component<MediaProviderProps, MediaProviderSt
7681

7782
@method
7883
load(target: HTMLElement | null | undefined) {
84+
this.#target = target || null;
85+
7986
// Hide underlying provider element from screen readers.
8087
target?.setAttribute('aria-hidden', 'true');
8188

89+
if (!this.#connected) return;
90+
8291
// Use a RAF here to prevent hot reloads resetting provider.
8392
window.cancelAnimationFrame(this.#loadRafId);
8493
this.#loadRafId = requestAnimationFrame(() => this.#runLoader(target));

0 commit comments

Comments
 (0)