Skip to content

Commit 6265f6c

Browse files
authored
fix: <vimeo-video> element race condition (#220)
* refactored load function for readability * Moved load decoupling to the top of the function (await this.#loadRequested = Promise.resolve) * Added state cleanup on disconnect * Switched approach to account for SSR and remounts * Added config old === new check to avoid unnecessary reloads; reordered loadComplete renewal to after the src check * Added connected callback
1 parent a88988c commit 6265f6c

1 file changed

Lines changed: 75 additions & 35 deletions

File tree

packages/vimeo-video-element/vimeo-video-element.js

Lines changed: 75 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ class VimeoVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement ??
109109
#videoWidth = NaN;
110110
#videoHeight = NaN;
111111
#config = null;
112+
/** Distinguishes a remount from SSR hydration.
113+
* See load()
114+
*/
115+
#wasDisconnected = false;
112116

113117
constructor() {
114118
super();
@@ -136,17 +140,13 @@ class VimeoVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement ??
136140
}
137141

138142
set config(value) {
143+
if (JSON.stringify(this.#config) === JSON.stringify(value)) return;
139144
this.#config = value;
145+
this.load();
140146
}
141147

142148
async load() {
143149
if (this.#loadRequested) return;
144-
145-
const isFirstLoad = !this.#hasLoaded;
146-
147-
if (this.#hasLoaded) this.loadComplete = new PublicPromise();
148-
this.#hasLoaded = true;
149-
150150
// Wait 1 tick to allow other attributes to be set.
151151
await (this.#loadRequested = Promise.resolve());
152152
this.#loadRequested = null;
@@ -169,9 +169,15 @@ class VimeoVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement ??
169169
this.api = null;
170170

171171
if (!this.src) {
172+
// Nothing to load. Leave loadComplete and #hasLoaded untouched so
173+
// callers awaiting the existing loadComplete aren't orphaned if a
174+
// later load() (e.g. triggered by a subsequent src) replaces it.
172175
return;
173176
}
174177

178+
if (this.#hasLoaded) this.loadComplete = new PublicPromise();
179+
this.#hasLoaded = true;
180+
175181
this.dispatchEvent(new Event('loadstart'));
176182

177183
// https://developer.vimeo.com/player/sdk/embed
@@ -187,50 +193,86 @@ class VimeoVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement ??
187193
...this.#config,
188194
};
189195

190-
const onLoaded = async () => {
191-
this.#readyState = 1; // HTMLMediaElement.HAVE_METADATA
192-
this.dispatchEvent(new Event('loadedmetadata'));
193-
194-
if (this.api) {
195-
this.#muted = await this.api.getMuted();
196-
this.#volume = await this.api.getVolume();
197-
this.dispatchEvent(new Event('volumechange'));
198-
199-
this.#duration = await this.api.getDuration();
200-
this.dispatchEvent(new Event('durationchange'));
201-
}
202-
203-
this.dispatchEvent(new Event('loadcomplete'));
204-
this.loadComplete.resolve();
205-
};
206-
207196
if (this.#isInit) {
208197
this.api = oldApi;
209198
await this.api.loadVideo({
210199
...options,
211200
url: this.src,
212201
});
213-
await onLoaded();
202+
await this.#onLoaded();
214203
await this.loadComplete;
215204
return;
216205
}
217206

218207
this.#isInit = true;
219208

220-
let iframe = this.shadowRoot?.querySelector('iframe');
209+
/*
210+
* Decide whether to build the iframe or adopt an existing one:
211+
* - First client mount or remount after disconnect: build, so the iframe
212+
* URL reflects current src and config. The Vimeo SDK wraps an existing
213+
* iframe as-is and won't update its URL.
214+
* - SSR declarative shadow DOM hydration: adopt the existing iframe. Its
215+
* URL is already correct, and rebuilding would destroy the in-flight
216+
* request and cause a visible stutter. Recover config from the
217+
* data-config attribute so element state matches the DOM.
218+
*/
219+
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
220+
221+
const existingIframe = this.shadowRoot.querySelector('iframe');
222+
const isSsrHydration = existingIframe && !this.#wasDisconnected;
223+
224+
if (isSsrHydration) {
225+
if (!this.#config) {
226+
this.#config = JSON.parse(existingIframe.getAttribute('data-config') || '{}');
227+
}
228+
} else {
229+
this.shadowRoot.innerHTML = getTemplateHTML(namedNodeMapToObject(this.attributes), this);
230+
}
231+
this.#wasDisconnected = false;
221232

222-
if (isFirstLoad && iframe) {
223-
this.#config = JSON.parse(iframe.getAttribute('data-config') || '{}');
233+
oldApi?.destroy?.();
234+
const iframe = this.shadowRoot.querySelector('iframe');
235+
this.api = new VimeoPlayerAPI(iframe);
236+
this.#setupApiListeners();
237+
await this.loadComplete;
238+
}
239+
240+
connectedCallback() {
241+
if (this.#wasDisconnected) {
242+
this.load();
224243
}
244+
super.connectedCallback?.();
245+
}
225246

226-
if (!this.shadowRoot) {
227-
this.attachShadow({ mode: 'open' });
228-
this.shadowRoot.innerHTML = getTemplateHTML(namedNodeMapToObject(this.attributes), this);
229-
iframe = this.shadowRoot.querySelector('iframe');
247+
disconnectedCallback() {
248+
this.#wasDisconnected = true;
249+
this.#loadRequested = null;
250+
this.#hasLoaded = null;
251+
this.#isInit = null;
252+
this.loadComplete = new PublicPromise();
253+
this.api?.destroy?.();
254+
this.api = null;
255+
super.disconnectedCallback?.();
256+
}
257+
258+
#onLoaded = async () => {
259+
this.#readyState = 1; // HTMLMediaElement.HAVE_METADATA
260+
this.dispatchEvent(new Event('loadedmetadata'));
261+
262+
if (this.api) {
263+
this.#muted = await this.api.getMuted();
264+
this.#volume = await this.api.getVolume();
265+
this.dispatchEvent(new Event('volumechange'));
266+
267+
this.#duration = await this.api.getDuration();
268+
this.dispatchEvent(new Event('durationchange'));
230269
}
231270

232-
this.api = new VimeoPlayerAPI(iframe);
271+
this.dispatchEvent(new Event('loadcomplete'));
272+
this.loadComplete.resolve();
273+
};
233274

275+
#setupApiListeners() {
234276
const textTracksVideo = document.createElement('video');
235277
this.textTracks = textTracksVideo.textTracks;
236278
this.api.getTextTracks().then((vimeoTracks) => {
@@ -249,7 +291,7 @@ class VimeoVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement ??
249291

250292
const onceLoaded = () => {
251293
this.api.off('loaded', onceLoaded);
252-
onLoaded();
294+
this.#onLoaded();
253295
};
254296
this.api.on('loaded', onceLoaded);
255297

@@ -333,8 +375,6 @@ class VimeoVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement ??
333375
this.#videoHeight = videoHeight;
334376
this.dispatchEvent(new Event('resize'));
335377
});
336-
337-
await this.loadComplete;
338378
}
339379

340380
async attributeChangedCallback(attrName, oldValue, newValue) {

0 commit comments

Comments
 (0)