Skip to content

Commit d959d4a

Browse files
authored
fix: make media bindings more robust (#12206)
The media bindings where fragile because the "prevent rerun if update in progress"-logic was flawed: It didn't (re)set the `updating` flag correctly, because it assumed an event and a render effect would always directly follow each other, with one always being first - but that's not true. It's much more robust to instead compare the value with what's currently present in the DOM. For the very fast-firing current-time-binding a variable is used to not invoke the DOM getter as much, for the others this is not necessary. Lastly, the playback-rate-binding contained another bug where the listener was setup inside the effect and therefore reinitialized on each binding change - it needs to move into a different effect.
1 parent 266f669 commit d959d4a

File tree

9 files changed

+135
-34
lines changed

9 files changed

+135
-34
lines changed

.changeset/popular-feet-rule.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: make media bindings more robust

packages/svelte/src/internal/client/dom/elements/bindings/media.js

+24-34
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ function time_ranges_to_array(ranges) {
2222
export function bind_current_time(media, get_value, update) {
2323
/** @type {number} */
2424
var raf_id;
25-
var updating = false;
25+
/** @type {number} */
26+
var value;
2627

2728
// Ideally, listening to timeupdate would be enough, but it fires too infrequently for the currentTime
2829
// binding, which is why we use a raf loop, too. We additionally still listen to timeupdate because
@@ -34,22 +35,21 @@ export function bind_current_time(media, get_value, update) {
3435
raf_id = requestAnimationFrame(callback);
3536
}
3637

37-
updating = true;
38-
update(media.currentTime);
38+
var next_value = media.currentTime;
39+
if (value !== next_value) {
40+
update((value = next_value));
41+
}
3942
};
4043

4144
raf_id = requestAnimationFrame(callback);
4245
media.addEventListener('timeupdate', callback);
4346

4447
render_effect(() => {
45-
var value = get_value();
48+
var next_value = Number(get_value());
4649

47-
// through isNaN we also allow number strings, which is more robust
48-
if (!updating && !isNaN(/** @type {any} */ (value))) {
49-
media.currentTime = /** @type {number} */ (value);
50+
if (value !== next_value && !isNaN(/** @type {any} */ (next_value))) {
51+
media.currentTime = value = next_value;
5052
}
51-
52-
updating = false;
5353
});
5454

5555
teardown(() => cancelAnimationFrame(raf_id));
@@ -113,22 +113,21 @@ export function bind_ready_state(media, update) {
113113
* @param {(playback_rate: number) => void} update
114114
*/
115115
export function bind_playback_rate(media, get_value, update) {
116-
var updating = false;
117-
118-
// Needs to happen after the element is inserted into the dom, else playback will be set back to 1 by the browser.
119-
// For hydration we could do it immediately but the additional code is not worth the lost microtask.
116+
// Needs to happen after element is inserted into the dom (which is guaranteed by using effect),
117+
// else playback will be set back to 1 by the browser
120118
effect(() => {
121-
var value = get_value();
119+
var value = Number(get_value());
122120

123-
// through isNaN we also allow number strings, which is more robust
124-
if (!isNaN(/** @type {any} */ (value)) && value !== media.playbackRate) {
125-
updating = true;
126-
media.playbackRate = /** @type {number} */ (value);
121+
if (value !== media.playbackRate && !isNaN(value)) {
122+
media.playbackRate = value;
127123
}
124+
});
128125

126+
// Start listening to ratechange events after the element is inserted into the dom,
127+
// else playback will be set to 1 by the browser
128+
effect(() => {
129129
listen(media, ['ratechange'], () => {
130-
if (!updating) update(media.playbackRate);
131-
updating = false;
130+
update(media.playbackRate);
132131
});
133132
});
134133
}
@@ -200,9 +199,7 @@ export function bind_paused(media, get_value, update) {
200199
* @param {(volume: number) => void} update
201200
*/
202201
export function bind_volume(media, get_value, update) {
203-
var updating = false;
204202
var callback = () => {
205-
updating = true;
206203
update(media.volume);
207204
};
208205

@@ -213,14 +210,11 @@ export function bind_volume(media, get_value, update) {
213210
listen(media, ['volumechange'], callback, false);
214211

215212
render_effect(() => {
216-
var value = get_value();
213+
var value = Number(get_value());
217214

218-
// through isNaN we also allow number strings, which is more robust
219-
if (!updating && !isNaN(/** @type {any} */ (value))) {
220-
media.volume = /** @type {number} */ (value);
215+
if (value !== media.volume && !isNaN(value)) {
216+
media.volume = value;
221217
}
222-
223-
updating = false;
224218
});
225219
}
226220

@@ -230,10 +224,7 @@ export function bind_volume(media, get_value, update) {
230224
* @param {(muted: boolean) => void} update
231225
*/
232226
export function bind_muted(media, get_value, update) {
233-
var updating = false;
234-
235227
var callback = () => {
236-
updating = true;
237228
update(media.muted);
238229
};
239230

@@ -244,9 +235,8 @@ export function bind_muted(media, get_value, update) {
244235
listen(media, ['volumechange'], callback, false);
245236

246237
render_effect(() => {
247-
var value = get_value();
238+
var value = !!get_value();
248239

249-
if (!updating) media.muted = !!value;
250-
updating = false;
240+
if (media.muted !== value) media.muted = value;
251241
});
252242
}

packages/svelte/tests/runtime-browser/assert.js

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function equal(a, b, message) {
4444
/**
4545
* @param {any} condition
4646
* @param {string} [message]
47+
* @returns {asserts condition}
4748
*/
4849
export function ok(condition, message) {
4950
if (!condition) throw new Error(message || `Expected ${condition} to be truthy`);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test, ok } from '../../assert';
2+
3+
export default test({
4+
mode: ['client'],
5+
async test({ assert, target }) {
6+
const audio = target.querySelector('audio');
7+
const button = target.querySelector('button');
8+
ok(audio);
9+
10+
assert.equal(audio.muted, false);
11+
12+
audio.muted = true;
13+
audio.dispatchEvent(new CustomEvent('volumechange'));
14+
await new Promise((r) => setTimeout(r, 100));
15+
assert.equal(audio.muted, true, 'event');
16+
17+
button?.click();
18+
await new Promise((r) => setTimeout(r, 100));
19+
assert.equal(audio.muted, false, 'click 1');
20+
21+
button?.click();
22+
await new Promise((r) => setTimeout(r, 100));
23+
assert.equal(audio.muted, true, 'click 2');
24+
25+
button?.click();
26+
await new Promise((r) => setTimeout(r, 100));
27+
assert.equal(audio.muted, false, 'click 3');
28+
}
29+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
let muted = $state(false);
3+
</script>
4+
5+
<audio bind:muted></audio>
6+
<button onclick={() => (muted = !muted)}>toggle</button>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test, ok } from '../../assert';
2+
3+
export default test({
4+
mode: ['client'],
5+
async test({ assert, target }) {
6+
const audio = target.querySelector('audio');
7+
const button = target.querySelector('button');
8+
ok(audio);
9+
10+
assert.equal(audio.playbackRate, 0.5);
11+
12+
audio.playbackRate = 1.0;
13+
audio.dispatchEvent(new CustomEvent('ratechange'));
14+
await new Promise((r) => setTimeout(r, 100));
15+
assert.equal(audio.playbackRate, 1.0);
16+
17+
button?.click();
18+
await new Promise((r) => setTimeout(r, 100));
19+
assert.equal(audio.playbackRate, 2);
20+
21+
button?.click();
22+
await new Promise((r) => setTimeout(r, 100));
23+
assert.equal(audio.playbackRate, 3);
24+
25+
button?.click();
26+
await new Promise((r) => setTimeout(r, 100));
27+
assert.equal(audio.playbackRate, 4);
28+
}
29+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
let playbackRate = $state(0.5);
3+
</script>
4+
5+
<audio bind:playbackRate></audio>
6+
<button onclick={() => (playbackRate += 1)}>increment</button>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test, ok } from '../../assert';
2+
3+
export default test({
4+
mode: ['client'],
5+
async test({ assert, target }) {
6+
const audio = target.querySelector('audio');
7+
const button = target.querySelector('button');
8+
ok(audio);
9+
10+
assert.equal(audio.volume, 0.1);
11+
12+
audio.volume = 0.2;
13+
audio.dispatchEvent(new CustomEvent('volumechange'));
14+
await new Promise((r) => setTimeout(r, 100));
15+
assert.equal(audio.volume, 0.2);
16+
17+
button?.click();
18+
await new Promise((r) => setTimeout(r, 100));
19+
assert.equal(audio.volume, 0.2 + 0.1); // JavaScript can't add floating point numbers correctly
20+
21+
button?.click();
22+
await new Promise((r) => setTimeout(r, 100));
23+
assert.equal(audio.volume, 0.2 + 0.1 + 0.1);
24+
25+
button?.click();
26+
await new Promise((r) => setTimeout(r, 100));
27+
assert.equal(audio.volume, 0.2 + 0.1 + 0.1 + 0.1);
28+
}
29+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
let volume = $state(0.1);
3+
</script>
4+
5+
<audio bind:volume></audio>
6+
<button onclick={() => (volume += 0.1)}>increment</button>

0 commit comments

Comments
 (0)