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 <matei_trand@yahoo.com>
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
pull/13038/head
Rich Harris 7 months ago committed by GitHub
parent 93ffb4dbeb
commit e73e63ed11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: use WAAPI to control timing of JS-based animations

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: never abort bidirectional transitions

@ -1,8 +1,7 @@
/** @import { AnimateFn, Animation, AnimationConfig, EachItem, Effect, Task, TransitionFn, TransitionManager } from '#client' */ /** @import { AnimateFn, Animation, AnimationConfig, EachItem, Effect, TransitionFn, TransitionManager } from '#client' */
import { noop, is_function } from '../../../shared/utils.js'; import { noop, is_function } from '../../../shared/utils.js';
import { effect } from '../../reactivity/effects.js'; import { effect } from '../../reactivity/effects.js';
import { current_effect, untrack } from '../../runtime.js'; import { current_effect, untrack } from '../../runtime.js';
import { raf } from '../../timing.js';
import { loop } from '../../loop.js'; import { loop } from '../../loop.js';
import { should_intro } from '../../render.js'; import { should_intro } from '../../render.js';
import { current_each_item } from '../blocks/each.js'; import { current_each_item } from '../blocks/each.js';
@ -97,17 +96,10 @@ export function animation(element, get_fn, get_params) {
) { ) {
const options = get_fn()(this.element, { from, to }, get_params?.()); const options = get_fn()(this.element, { from, to }, get_params?.());
animation = animate( animation = animate(this.element, options, undefined, 1, () => {
this.element,
options,
undefined,
1,
() => {
animation?.abort(); animation?.abort();
animation = undefined; animation = undefined;
}, });
undefined
);
} }
}, },
fix() { fix() {
@ -192,14 +184,13 @@ export function transition(flags, element, get_fn, get_params) {
/** @type {Animation | undefined} */ /** @type {Animation | undefined} */
var outro; var outro;
/** @type {(() => void) | undefined} */
var reset;
function get_options() { function get_options() {
// If a transition is still ongoing, we use the existing options rather than generating // If a transition is still ongoing, we use the existing options rather than generating
// new ones. This ensures that reversible transitions reverse smoothly, rather than // new ones. This ensures that reversible transitions reverse smoothly, rather than
// jumping to a new spot because (for example) a different `duration` was used // jumping to a new spot because (for example) a different `duration` was used
return (current_options ??= get_fn()(element, get_params?.(), { direction })); return (current_options ??= get_fn()(element, get_params?.() ?? /** @type {P} */ ({}), {
direction
}));
} }
/** @type {TransitionManager} */ /** @type {TransitionManager} */
@ -208,65 +199,43 @@ export function transition(flags, element, get_fn, get_params) {
in() { in() {
element.inert = inert; element.inert = inert;
// abort the outro to prevent overlap with the intro if (!is_intro) {
outro?.abort(); outro?.abort();
// abort previous intro (can happen if an element is intro'd, then outro'd, then intro'd again) outro?.reset?.();
return;
}
if (!is_outro) {
// if we intro then outro then intro again, we want to abort the first intro,
// if it's not a bidirectional transition
intro?.abort(); intro?.abort();
}
if (is_intro) {
dispatch_event(element, 'introstart'); dispatch_event(element, 'introstart');
intro = animate(
element, intro = animate(element, get_options(), outro, 1, () => {
get_options(),
outro,
1,
() => {
dispatch_event(element, 'introend'); dispatch_event(element, 'introend');
// Ensure we cancel the animation to prevent leaking // Ensure we cancel the animation to prevent leaking
intro?.abort(); intro?.abort();
intro = current_options = undefined; intro = current_options = undefined;
}, });
is_both
? undefined
: () => {
intro = current_options = undefined;
}
);
} else {
reset?.();
}
}, },
out(fn) { out(fn) {
// abort previous outro (can happen if an element is outro'd, then intro'd, then outro'd again) if (!is_outro) {
outro?.abort(); fn?.();
current_options = undefined;
return;
}
if (is_outro) {
element.inert = true; element.inert = true;
dispatch_event(element, 'outrostart'); dispatch_event(element, 'outrostart');
outro = animate(
element,
get_options(),
intro,
0,
() => {
dispatch_event(element, 'outroend');
outro = current_options = undefined;
fn?.();
},
is_both
? undefined
: () => {
outro = current_options = undefined;
}
);
// TODO arguably the outro should never null itself out until _all_ outros for this effect have completed... outro = animate(element, get_options(), intro, 0, () => {
// in that case we wouldn't need to store `reset` separately dispatch_event(element, 'outroend');
reset = outro.reset;
} else {
fn?.(); fn?.();
} });
}, },
stop: () => { stop: () => {
intro?.abort(); intro?.abort();
@ -282,7 +251,7 @@ export function transition(flags, element, get_fn, get_params) {
// parent (block) effect is where the state change happened. we can determine that by // parent (block) effect is where the state change happened. we can determine that by
// looking at whether the block effect is currently initializing // looking at whether the block effect is currently initializing
if (is_intro && should_intro) { if (is_intro && should_intro) {
let run = is_global; var run = is_global;
if (!run) { if (!run) {
var block = /** @type {Effect | null} */ (e.parent); var block = /** @type {Effect | null} */ (e.parent);
@ -311,17 +280,16 @@ export function transition(flags, element, get_fn, get_params) {
* @param {AnimationConfig | ((opts: { direction: 'in' | 'out' }) => AnimationConfig)} options * @param {AnimationConfig | ((opts: { direction: 'in' | 'out' }) => AnimationConfig)} options
* @param {Animation | undefined} counterpart The corresponding intro/outro to this outro/intro * @param {Animation | undefined} counterpart The corresponding intro/outro to this outro/intro
* @param {number} t2 The target `t` value `1` for intro, `0` for outro * @param {number} t2 The target `t` value `1` for intro, `0` for outro
* @param {(() => void) | undefined} on_finish Called after successfully completing the animation * @param {(() => void)} on_finish Called after successfully completing the animation
* @param {(() => void) | undefined} on_abort Called if the animation is aborted
* @returns {Animation} * @returns {Animation}
*/ */
function animate(element, options, counterpart, t2, on_finish, on_abort) { function animate(element, options, counterpart, t2, on_finish) {
var is_intro = t2 === 1; var is_intro = t2 === 1;
if (is_function(options)) { if (is_function(options)) {
// In the case of a deferred transition (such as `crossfade`), `option` will be // In the case of a deferred transition (such as `crossfade`), `option` will be
// a function rather than an `AnimationConfig`. We need to call this function // a function rather than an `AnimationConfig`. We need to call this function
// once DOM has been updated... // once the DOM has been updated...
/** @type {Animation} */ /** @type {Animation} */
var a; var a;
var aborted = false; var aborted = false;
@ -329,7 +297,7 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
queue_micro_task(() => { queue_micro_task(() => {
if (aborted) return; if (aborted) return;
var o = options({ direction: is_intro ? 'in' : 'out' }); var o = options({ direction: is_intro ? 'in' : 'out' });
a = animate(element, o, counterpart, t2, on_finish, on_abort); a = animate(element, o, counterpart, t2, on_finish);
}); });
// ...but we want to do so without using `async`/`await` everywhere, so // ...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) {
}, },
deactivate: () => a.deactivate(), deactivate: () => a.deactivate(),
reset: () => a.reset(), reset: () => a.reset(),
t: (now) => a.t(now) t: () => a.t()
}; };
} }
counterpart?.deactivate(); counterpart?.deactivate();
if (!options?.duration) { if (!options?.duration) {
on_finish?.(); on_finish();
return { return {
abort: noop, abort: noop,
deactivate: noop, deactivate: noop,
@ -359,90 +328,73 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
const { delay = 0, css, tick, easing = linear } = options; const { delay = 0, css, tick, easing = linear } = options;
var start = raf.now() + delay; var keyframes = [];
var t1 = counterpart?.t(start) ?? 1 - t2;
var delta = t2 - t1;
var duration = options.duration * Math.abs(delta); if (is_intro && counterpart === undefined) {
var end = start + duration; if (tick) {
tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
}
/** @type {globalThis.Animation} */ if (css) {
var animation; var styles = css_to_keyframe(css(0, 1));
keyframes.push(styles, styles);
}
}
/** @type {Task} */ var get_t = () => 1 - t2;
var task;
if (css) { // create a dummy animation that lasts as long as the delay (but with whatever devtools
// run after a micro task so that all transitions that are lining up and are about to run can correctly measure the DOM // multiplier is in effect). in the common case that it is `0`, we keep it anyway so that
queue_micro_task(() => { // the CSS keyframes aren't created until the DOM is updated
// WAAPI var animation = element.animate(keyframes, { duration: delay });
animation.onfinish = () => {
// for bidirectional transitions, we start from the current position,
// rather than doing a full intro/outro
var t1 = counterpart?.t() ?? 1 - t2;
counterpart?.abort();
var delta = t2 - t1;
var duration = /** @type {number} */ (options.duration) * Math.abs(delta);
var keyframes = []; var keyframes = [];
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
// In case of a delayed intro, apply the initial style for the duration of the delay; if (css) {
// else in case of a fade-in for example the element would be visible until the animation starts var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
if (is_intro && delay > 0) {
let m = Math.ceil(delay / (1000 / 60));
let keyframe = css_to_keyframe(css(0, 1));
for (let i = 0; i < m; i += 1) {
keyframes.push(keyframe);
}
}
for (var i = 0; i <= n; i += 1) { for (var i = 0; i <= n; i += 1) {
var t = t1 + delta * easing(i / n); var t = t1 + delta * easing(i / n);
var styles = css(t, 1 - t); var styles = css(t, 1 - t);
keyframes.push(css_to_keyframe(styles)); keyframes.push(css_to_keyframe(styles));
} }
}
animation = element.animate(keyframes, { animation = element.animate(keyframes, { duration, fill: 'forwards' });
delay: is_intro ? 0 : delay,
duration: duration + (is_intro ? delay : 0),
easing: 'linear',
fill: 'forwards'
});
animation.finished animation.onfinish = () => {
.then(() => { get_t = () => t2;
on_finish?.(); tick?.(t2, 1 - t2);
on_finish();
};
if (t2 === 1) { get_t = () => {
animation.cancel(); var time = /** @type {number} */ (
} /** @type {globalThis.Animation} */ (animation).currentTime
}) );
.catch((e) => {
// Error for DOMException: The user aborted a request. This results in two things:
// - startTime is `null`
// - currentTime is `null`
// We can't use the existence of an AbortError as this error and error code is shared
// with other Web APIs such as fetch().
if (animation.startTime !== null && animation.currentTime !== null) { return t1 + delta * easing(time / duration);
throw e; };
}
});
});
} else {
// Timer
if (t1 === 0) {
tick?.(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
}
task = loop((now) => { if (tick) {
if (now >= end) { loop(() => {
tick?.(t2, 1 - t2); if (animation.playState !== 'running') return false;
on_finish?.();
return false;
}
if (now >= start) { var t = get_t();
var p = t1 + delta * easing((now - start) / duration); tick(t, 1 - t);
tick?.(p, 1 - p);
}
return true; return true;
}); });
} }
};
return { return {
abort: () => { abort: () => {
@ -451,23 +403,15 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
// This prevents memory leaks in Chromium // This prevents memory leaks in Chromium
animation.effect = null; animation.effect = null;
} }
task?.abort();
on_abort?.();
on_finish = undefined;
on_abort = undefined;
}, },
deactivate: () => { deactivate: () => {
on_finish = undefined; on_finish = noop;
on_abort = undefined;
}, },
reset: () => { reset: () => {
if (t2 === 0) { if (t2 === 0) {
tick?.(1, 0); tick?.(1, 0);
} }
}, },
t: (now) => { t: () => get_t()
var t = t1 + delta * easing((now - start) / duration);
return Math.min(1, Math.max(0, t));
}
}; };
} }

@ -121,7 +121,7 @@ export interface Animation {
/** 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 */ /** 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 */
reset: () => void; reset: () => void;
/** Get the `t` value (between `0` and `1`) of the animation, so that its counterpart can start from the right place */ /** Get the `t` value (between `0` and `1`) of the animation, so that its counterpart can start from the right place */
t: (now: number) => number; t: () => number;
} }
export type TransitionFn<P> = ( export type TransitionFn<P> = (

@ -1,4 +1,6 @@
import { flushSync } from 'svelte';
import { raf as svelte_raf } from 'svelte/internal/client'; import { raf as svelte_raf } from 'svelte/internal/client';
import { queue_micro_task } from '../src/internal/client/dom/task.js';
export const raf = { export const raf = {
animations: new Set(), animations: new Set(),
@ -23,6 +25,7 @@ export const raf = {
*/ */
function tick(time) { function tick(time) {
raf.time = time; raf.time = time;
flushSync();
for (const animation of raf.animations) { for (const animation of raf.animations) {
animation._update(); animation._update();
} }
@ -38,12 +41,16 @@ class Animation {
#offset = raf.time; #offset = raf.time;
#finished = () => {}; /** @type {Function} */
#cancelled = () => {}; #onfinish = () => {};
/** @type {Function} */
#oncancel = () => {};
target; target;
currentTime = 0; currentTime = 0;
startTime = 0; startTime = 0;
playState = 'running';
/** /**
* @param {HTMLElement} target * @param {HTMLElement} target
@ -53,24 +60,9 @@ class Animation {
constructor(target, keyframes, { duration, delay }) { constructor(target, keyframes, { duration, delay }) {
this.target = target; this.target = target;
this.#keyframes = keyframes; this.#keyframes = keyframes;
this.#duration = duration; this.#duration = Math.round(duration);
this.#delay = delay ?? 0; this.#delay = delay ?? 0;
// Promise-like semantics, but call callbacks immediately on raf.tick
this.finished = {
/** @param {() => void} callback */
then: (callback) => {
this.#finished = callback;
return {
/** @param {() => void} callback */
catch: (callback) => {
this.#cancelled = callback;
}
};
}
};
this._update(); this._update();
} }
@ -82,7 +74,7 @@ class Animation {
this.#apply_keyframe(target_frame); this.#apply_keyframe(target_frame);
if (this.currentTime >= this.#duration) { if (this.currentTime >= this.#duration) {
this.#finished(); this.#onfinish();
raf.animations.delete(this); raf.animations.delete(this);
} }
} }
@ -131,9 +123,31 @@ class Animation {
this.currentTime = null; this.currentTime = null;
// @ts-ignore // @ts-ignore
this.startTime = null; this.startTime = null;
this.#cancelled();
this.playState = 'idle';
this.#oncancel();
raf.animations.delete(this); raf.animations.delete(this);
} }
/** @param {() => {}} fn */
set onfinish(fn) {
if (this.#duration === 0) {
queue_micro_task(fn);
} else {
this.#onfinish = () => {
fn();
this.#onfinish = () => {};
};
}
}
/** @param {() => {}} fn */
set oncancel(fn) {
this.#oncancel = () => {
fn();
this.#oncancel = () => {};
};
}
} }
/** /**

@ -0,0 +1,30 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../test';
export default test({
test({ assert, component, target, raf, logs }) {
const button = target.querySelector('button');
flushSync(() => button?.click());
const div = target.querySelector('div');
ok(div);
let ended = 0;
div.addEventListener('introend', () => {
ended += 1;
});
assert.equal(div.style.scale, '0');
assert.deepEqual(logs, ['tick: 0']);
raf.tick(50);
assert.equal(div.style.scale, '0.5');
assert.deepEqual(logs, ['tick: 0', 'tick: 0.5']);
raf.tick(100);
assert.equal(div.style.scale, '');
assert.deepEqual(logs, ['tick: 0', 'tick: 0.5', 'tick: 1']);
assert.equal(ended, 1);
}
});

@ -0,0 +1,21 @@
<script>
let visible = false;
function foo() {
return {
duration: 100,
css: (t) => {
return `scale: ${t}`;
},
tick: (t) => {
console.log(`tick: ${t}`);
}
};
}
</script>
<button on:click={() => (visible = !visible)}>toggle</button>
{#if visible}
<div transition:foo></div>
{/if}

@ -18,6 +18,11 @@ export default test({
assert.equal(spans[1].foo, undefined); assert.equal(spans[1].foo, undefined);
assert.equal(spans[2].foo, undefined); assert.equal(spans[2].foo, undefined);
// intermediate ticks necessary for testing purposes, so that time
// elapses after the initial delay animation's onfinish callback runs
raf.tick(50);
raf.tick(100);
raf.tick(125); raf.tick(125);
assert.equal(spans[0].foo, 0); assert.equal(spans[0].foo, 0);
assert.equal(spans[1].foo, 0.25); assert.equal(spans[1].foo, 0.25);
@ -36,10 +41,6 @@ export default test({
` `
); );
spans = /** @type {NodeListOf<HTMLSpanElement & { foo: number }>} */ (
target.querySelectorAll('span')
);
assert.equal(spans[0].foo, 1); assert.equal(spans[0].foo, 1);
assert.equal(spans[1].foo, 1); assert.equal(spans[1].foo, 1);
assert.equal(spans[2].foo, 1); assert.equal(spans[2].foo, 1);

@ -4,7 +4,7 @@
let foo_text; let foo_text;
let bar_text; let bar_text;
function foo(node, params) { function foo(node, { duration = 100 }) {
foo_text = node.textContent; foo_text = node.textContent;
return () => { return () => {
@ -13,7 +13,7 @@
} }
return { return {
duration: 100, duration,
tick: t => { tick: t => {
node.foo = t; node.foo = t;
} }
@ -21,7 +21,7 @@
}; };
} }
function bar(node, params) { function bar(node, { duration = 100 }) {
bar_text = node.textContent; bar_text = node.textContent;
return () => { return () => {
@ -30,7 +30,7 @@
} }
return { return {
duration: 100, duration,
tick: t => { tick: t => {
node.foo = t; node.foo = t;
} }

@ -18,7 +18,7 @@ export default test({
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>' '<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
); );
raf.tick(99); raf.tick(100);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>' '<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
@ -39,6 +39,8 @@ export default test({
raf.tick(275); raf.tick(275);
assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>'); assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>');
raf.tick(300);
raf.tick(350); raf.tick(350);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,

Loading…
Cancel
Save