Skip to content

Commit e73e63e

Browse files
Rich-HarrisrChaozdummdidumm
authored
fix: use WAAPI to control timing of JS-based animations (#13018)
This makes it possible to slow them down using dev tools, and overall ties the implementation more closely to WAAPI, which is good. Also fixes #12730 (all four cases, css, tick, css+tick, neither are now supported) and fixes #13019 (passed empty fallback object) --------- Co-authored-by: Matei Trandafir <[email protected]> Co-authored-by: Simon H <[email protected]>
1 parent 93ffb4d commit e73e63e

File tree

12 files changed

+211
-189
lines changed

12 files changed

+211
-189
lines changed

.changeset/five-shirts-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: use WAAPI to control timing of JS-based animations

.changeset/slimy-news-help.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: never abort bidirectional transitions

packages/svelte/src/internal/client/dom/elements/transitions.js

Lines changed: 100 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
/** @import { AnimateFn, Animation, AnimationConfig, EachItem, Effect, Task, TransitionFn, TransitionManager } from '#client' */
1+
/** @import { AnimateFn, Animation, AnimationConfig, EachItem, Effect, TransitionFn, TransitionManager } from '#client' */
22
import { noop, is_function } from '../../../shared/utils.js';
33
import { effect } from '../../reactivity/effects.js';
44
import { current_effect, untrack } from '../../runtime.js';
5-
import { raf } from '../../timing.js';
65
import { loop } from '../../loop.js';
76
import { should_intro } from '../../render.js';
87
import { current_each_item } from '../blocks/each.js';
@@ -97,17 +96,10 @@ export function animation(element, get_fn, get_params) {
9796
) {
9897
const options = get_fn()(this.element, { from, to }, get_params?.());
9998

100-
animation = animate(
101-
this.element,
102-
options,
103-
undefined,
104-
1,
105-
() => {
106-
animation?.abort();
107-
animation = undefined;
108-
},
109-
undefined
110-
);
99+
animation = animate(this.element, options, undefined, 1, () => {
100+
animation?.abort();
101+
animation = undefined;
102+
});
111103
}
112104
},
113105
fix() {
@@ -192,14 +184,13 @@ export function transition(flags, element, get_fn, get_params) {
192184
/** @type {Animation | undefined} */
193185
var outro;
194186

195-
/** @type {(() => void) | undefined} */
196-
var reset;
197-
198187
function get_options() {
199188
// If a transition is still ongoing, we use the existing options rather than generating
200189
// new ones. This ensures that reversible transitions reverse smoothly, rather than
201190
// jumping to a new spot because (for example) a different `duration` was used
202-
return (current_options ??= get_fn()(element, get_params?.(), { direction }));
191+
return (current_options ??= get_fn()(element, get_params?.() ?? /** @type {P} */ ({}), {
192+
direction
193+
}));
203194
}
204195

205196
/** @type {TransitionManager} */
@@ -208,65 +199,43 @@ export function transition(flags, element, get_fn, get_params) {
208199
in() {
209200
element.inert = inert;
210201

211-
// abort the outro to prevent overlap with the intro
212-
outro?.abort();
213-
// abort previous intro (can happen if an element is intro'd, then outro'd, then intro'd again)
214-
intro?.abort();
202+
if (!is_intro) {
203+
outro?.abort();
204+
outro?.reset?.();
205+
return;
206+
}
215207

216-
if (is_intro) {
217-
dispatch_event(element, 'introstart');
218-
intro = animate(
219-
element,
220-
get_options(),
221-
outro,
222-
1,
223-
() => {
224-
dispatch_event(element, 'introend');
225-
// Ensure we cancel the animation to prevent leaking
226-
intro?.abort();
227-
intro = current_options = undefined;
228-
},
229-
is_both
230-
? undefined
231-
: () => {
232-
intro = current_options = undefined;
233-
}
234-
);
235-
} else {
236-
reset?.();
208+
if (!is_outro) {
209+
// if we intro then outro then intro again, we want to abort the first intro,
210+
// if it's not a bidirectional transition
211+
intro?.abort();
237212
}
213+
214+
dispatch_event(element, 'introstart');
215+
216+
intro = animate(element, get_options(), outro, 1, () => {
217+
dispatch_event(element, 'introend');
218+
219+
// Ensure we cancel the animation to prevent leaking
220+
intro?.abort();
221+
intro = current_options = undefined;
222+
});
238223
},
239224
out(fn) {
240-
// abort previous outro (can happen if an element is outro'd, then intro'd, then outro'd again)
241-
outro?.abort();
242-
243-
if (is_outro) {
244-
element.inert = true;
245-
246-
dispatch_event(element, 'outrostart');
247-
outro = animate(
248-
element,
249-
get_options(),
250-
intro,
251-
0,
252-
() => {
253-
dispatch_event(element, 'outroend');
254-
outro = current_options = undefined;
255-
fn?.();
256-
},
257-
is_both
258-
? undefined
259-
: () => {
260-
outro = current_options = undefined;
261-
}
262-
);
263-
264-
// TODO arguably the outro should never null itself out until _all_ outros for this effect have completed...
265-
// in that case we wouldn't need to store `reset` separately
266-
reset = outro.reset;
267-
} else {
225+
if (!is_outro) {
268226
fn?.();
227+
current_options = undefined;
228+
return;
269229
}
230+
231+
element.inert = true;
232+
233+
dispatch_event(element, 'outrostart');
234+
235+
outro = animate(element, get_options(), intro, 0, () => {
236+
dispatch_event(element, 'outroend');
237+
fn?.();
238+
});
270239
},
271240
stop: () => {
272241
intro?.abort();
@@ -282,7 +251,7 @@ export function transition(flags, element, get_fn, get_params) {
282251
// parent (block) effect is where the state change happened. we can determine that by
283252
// looking at whether the block effect is currently initializing
284253
if (is_intro && should_intro) {
285-
let run = is_global;
254+
var run = is_global;
286255

287256
if (!run) {
288257
var block = /** @type {Effect | null} */ (e.parent);
@@ -311,25 +280,24 @@ export function transition(flags, element, get_fn, get_params) {
311280
* @param {AnimationConfig | ((opts: { direction: 'in' | 'out' }) => AnimationConfig)} options
312281
* @param {Animation | undefined} counterpart The corresponding intro/outro to this outro/intro
313282
* @param {number} t2 The target `t` value — `1` for intro, `0` for outro
314-
* @param {(() => void) | undefined} on_finish Called after successfully completing the animation
315-
* @param {(() => void) | undefined} on_abort Called if the animation is aborted
283+
* @param {(() => void)} on_finish Called after successfully completing the animation
316284
* @returns {Animation}
317285
*/
318-
function animate(element, options, counterpart, t2, on_finish, on_abort) {
286+
function animate(element, options, counterpart, t2, on_finish) {
319287
var is_intro = t2 === 1;
320288

321289
if (is_function(options)) {
322290
// In the case of a deferred transition (such as `crossfade`), `option` will be
323291
// a function rather than an `AnimationConfig`. We need to call this function
324-
// once DOM has been updated...
292+
// once the DOM has been updated...
325293
/** @type {Animation} */
326294
var a;
327295
var aborted = false;
328296

329297
queue_micro_task(() => {
330298
if (aborted) return;
331299
var o = options({ direction: is_intro ? 'in' : 'out' });
332-
a = animate(element, o, counterpart, t2, on_finish, on_abort);
300+
a = animate(element, o, counterpart, t2, on_finish);
333301
});
334302

335303
// ...but we want to do so without using `async`/`await` everywhere, so
@@ -341,14 +309,15 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
341309
},
342310
deactivate: () => a.deactivate(),
343311
reset: () => a.reset(),
344-
t: (now) => a.t(now)
312+
t: () => a.t()
345313
};
346314
}
347315

348316
counterpart?.deactivate();
349317

350318
if (!options?.duration) {
351-
on_finish?.();
319+
on_finish();
320+
352321
return {
353322
abort: noop,
354323
deactivate: noop,
@@ -359,90 +328,73 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
359328

360329
const { delay = 0, css, tick, easing = linear } = options;
361330

362-
var start = raf.now() + delay;
363-
var t1 = counterpart?.t(start) ?? 1 - t2;
364-
var delta = t2 - t1;
331+
var keyframes = [];
365332

366-
var duration = options.duration * Math.abs(delta);
367-
var end = start + duration;
333+
if (is_intro && counterpart === undefined) {
334+
if (tick) {
335+
tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
336+
}
368337

369-
/** @type {globalThis.Animation} */
370-
var animation;
338+
if (css) {
339+
var styles = css_to_keyframe(css(0, 1));
340+
keyframes.push(styles, styles);
341+
}
342+
}
371343

372-
/** @type {Task} */
373-
var task;
344+
var get_t = () => 1 - t2;
374345

375-
if (css) {
376-
// run after a micro task so that all transitions that are lining up and are about to run can correctly measure the DOM
377-
queue_micro_task(() => {
378-
// WAAPI
379-
var keyframes = [];
380-
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
346+
// create a dummy animation that lasts as long as the delay (but with whatever devtools
347+
// multiplier is in effect). in the common case that it is `0`, we keep it anyway so that
348+
// the CSS keyframes aren't created until the DOM is updated
349+
var animation = element.animate(keyframes, { duration: delay });
381350

382-
// In case of a delayed intro, apply the initial style for the duration of the delay;
383-
// else in case of a fade-in for example the element would be visible until the animation starts
384-
if (is_intro && delay > 0) {
385-
let m = Math.ceil(delay / (1000 / 60));
386-
let keyframe = css_to_keyframe(css(0, 1));
387-
for (let i = 0; i < m; i += 1) {
388-
keyframes.push(keyframe);
389-
}
390-
}
351+
animation.onfinish = () => {
352+
// for bidirectional transitions, we start from the current position,
353+
// rather than doing a full intro/outro
354+
var t1 = counterpart?.t() ?? 1 - t2;
355+
counterpart?.abort();
356+
357+
var delta = t2 - t1;
358+
var duration = /** @type {number} */ (options.duration) * Math.abs(delta);
359+
var keyframes = [];
360+
361+
if (css) {
362+
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
391363

392364
for (var i = 0; i <= n; i += 1) {
393365
var t = t1 + delta * easing(i / n);
394366
var styles = css(t, 1 - t);
395367
keyframes.push(css_to_keyframe(styles));
396368
}
369+
}
397370

398-
animation = element.animate(keyframes, {
399-
delay: is_intro ? 0 : delay,
400-
duration: duration + (is_intro ? delay : 0),
401-
easing: 'linear',
402-
fill: 'forwards'
403-
});
371+
animation = element.animate(keyframes, { duration, fill: 'forwards' });
404372

405-
animation.finished
406-
.then(() => {
407-
on_finish?.();
408-
409-
if (t2 === 1) {
410-
animation.cancel();
411-
}
412-
})
413-
.catch((e) => {
414-
// Error for DOMException: The user aborted a request. This results in two things:
415-
// - startTime is `null`
416-
// - currentTime is `null`
417-
// We can't use the existence of an AbortError as this error and error code is shared
418-
// with other Web APIs such as fetch().
419-
420-
if (animation.startTime !== null && animation.currentTime !== null) {
421-
throw e;
422-
}
423-
});
424-
});
425-
} else {
426-
// Timer
427-
if (t1 === 0) {
428-
tick?.(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
429-
}
373+
animation.onfinish = () => {
374+
get_t = () => t2;
375+
tick?.(t2, 1 - t2);
376+
on_finish();
377+
};
430378

431-
task = loop((now) => {
432-
if (now >= end) {
433-
tick?.(t2, 1 - t2);
434-
on_finish?.();
435-
return false;
436-
}
379+
get_t = () => {
380+
var time = /** @type {number} */ (
381+
/** @type {globalThis.Animation} */ (animation).currentTime
382+
);
437383

438-
if (now >= start) {
439-
var p = t1 + delta * easing((now - start) / duration);
440-
tick?.(p, 1 - p);
441-
}
384+
return t1 + delta * easing(time / duration);
385+
};
442386

443-
return true;
444-
});
445-
}
387+
if (tick) {
388+
loop(() => {
389+
if (animation.playState !== 'running') return false;
390+
391+
var t = get_t();
392+
tick(t, 1 - t);
393+
394+
return true;
395+
});
396+
}
397+
};
446398

447399
return {
448400
abort: () => {
@@ -451,23 +403,15 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
451403
// This prevents memory leaks in Chromium
452404
animation.effect = null;
453405
}
454-
task?.abort();
455-
on_abort?.();
456-
on_finish = undefined;
457-
on_abort = undefined;
458406
},
459407
deactivate: () => {
460-
on_finish = undefined;
461-
on_abort = undefined;
408+
on_finish = noop;
462409
},
463410
reset: () => {
464411
if (t2 === 0) {
465412
tick?.(1, 0);
466413
}
467414
},
468-
t: (now) => {
469-
var t = t1 + delta * easing((now - start) / duration);
470-
return Math.min(1, Math.max(0, t));
471-
}
415+
t: () => get_t()
472416
};
473417
}

packages/svelte/src/internal/client/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export interface Animation {
121121
/** Resets an animation to its starting state, if it uses `tick`. Exposed as a separate method so that an aborted `out:` can still reset even if the `outro` had already completed */
122122
reset: () => void;
123123
/** Get the `t` value (between `0` and `1`) of the animation, so that its counterpart can start from the right place */
124-
t: (now: number) => number;
124+
t: () => number;
125125
}
126126

127127
export type TransitionFn<P> = (

0 commit comments

Comments
 (0)