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 4 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 { effect } from '../../reactivity/effects.js';
import { current_effect, untrack } from '../../runtime.js';
import { raf } from '../../timing.js';
import { loop } from '../../loop.js';
import { should_intro } from '../../render.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?.());
animation = animate(
this.element,
options,
undefined,
1,
() => {
animation?.abort();
animation = undefined;
},
undefined
);
animation = animate(this.element, options, undefined, 1, () => {
animation?.abort();
animation = undefined;
});
}
},
fix() {
@ -192,14 +184,13 @@ export function transition(flags, element, get_fn, get_params) {
/** @type {Animation | undefined} */
var outro;
/** @type {(() => void) | undefined} */
var reset;
function get_options() {
// 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
// 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} */
@ -208,65 +199,43 @@ export function transition(flags, element, get_fn, get_params) {
in() {
element.inert = inert;
// abort the outro to prevent overlap with the intro
outro?.abort();
// abort previous intro (can happen if an element is intro'd, then outro'd, then intro'd again)
intro?.abort();
if (!is_intro) {
outro?.abort();
outro?.reset?.();
return;
}
if (is_intro) {
dispatch_event(element, 'introstart');
intro = animate(
element,
get_options(),
outro,
1,
() => {
dispatch_event(element, 'introend');
// Ensure we cancel the animation to prevent leaking
intro?.abort();
intro = current_options = undefined;
},
is_both
? undefined
: () => {
intro = current_options = undefined;
}
);
} else {
reset?.();
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();
}
dispatch_event(element, 'introstart');
intro = animate(element, get_options(), outro, 1, () => {
dispatch_event(element, 'introend');
// Ensure we cancel the animation to prevent leaking
intro?.abort();
intro = current_options = undefined;
});
},
out(fn) {
// abort previous outro (can happen if an element is outro'd, then intro'd, then outro'd again)
outro?.abort();
if (is_outro) {
element.inert = true;
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...
// in that case we wouldn't need to store `reset` separately
reset = outro.reset;
} else {
if (!is_outro) {
fn?.();
current_options = undefined;
return;
}
element.inert = true;
dispatch_event(element, 'outrostart');
outro = animate(element, get_options(), intro, 0, () => {
dispatch_event(element, 'outroend');
fn?.();
});
},
stop: () => {
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
// looking at whether the block effect is currently initializing
if (is_intro && should_intro) {
let run = is_global;
var run = is_global;
if (!run) {
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 {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 {(() => void) | undefined} on_finish Called after successfully completing the animation
* @param {(() => void) | undefined} on_abort Called if the animation is aborted
* @param {(() => void)} on_finish Called after successfully completing the 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;
if (is_function(options)) {
// 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
// once DOM has been updated...
// once the DOM has been updated...
/** @type {Animation} */
var a;
var aborted = false;
@ -329,7 +297,7 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
queue_micro_task(() => {
if (aborted) return;
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
@ -341,14 +309,15 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
},
deactivate: () => a.deactivate(),
reset: () => a.reset(),
t: (now) => a.t(now)
t: () => a.t()
};
}
counterpart?.deactivate();
if (!options?.duration) {
on_finish?.();
on_finish();
return {
abort: 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;
var start = raf.now() + delay;
var t1 = counterpart?.t(start) ?? 1 - t2;
var delta = t2 - t1;
var keyframes = [];
var duration = options.duration * Math.abs(delta);
var end = start + duration;
if (is_intro && counterpart === undefined) {
if (tick) {
tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
}
/** @type {globalThis.Animation} */
var animation;
if (css) {
var styles = css_to_keyframe(css(0, 1));
keyframes.push(styles, styles);
}
}
/** @type {Task} */
var task;
var get_t = () => 1 - t2;
if (css) {
// run after a micro task so that all transitions that are lining up and are about to run can correctly measure the DOM
queue_micro_task(() => {
// WAAPI
var keyframes = [];
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
// create a dummy animation that lasts as long as the delay (but with whatever devtools
// multiplier is in effect). in the common case that it is `0`, we keep it anyway so that
// the CSS keyframes aren't created until the DOM is updated
var animation = element.animate(keyframes, { duration: delay });
// In case of a delayed intro, apply the initial style for the duration of the delay;
// else in case of a fade-in for example the element would be visible until the animation starts
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);
}
}
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 = [];
if (css) {
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
for (var i = 0; i <= n; i += 1) {
var t = t1 + delta * easing(i / n);
var styles = css(t, 1 - t);
keyframes.push(css_to_keyframe(styles));
}
}
animation = element.animate(keyframes, {
delay: is_intro ? 0 : delay,
duration: duration + (is_intro ? delay : 0),
easing: 'linear',
fill: 'forwards'
});
animation = element.animate(keyframes, { duration, fill: 'forwards' });
animation.finished
.then(() => {
on_finish?.();
if (t2 === 1) {
animation.cancel();
}
})
.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) {
throw e;
}
});
});
} else {
// Timer
if (t1 === 0) {
tick?.(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
}
animation.onfinish = () => {
get_t = () => t2;
tick?.(t2, 1 - t2);
on_finish();
};
task = loop((now) => {
if (now >= end) {
tick?.(t2, 1 - t2);
on_finish?.();
return false;
}
get_t = () => {
var time = /** @type {number} */ (
/** @type {globalThis.Animation} */ (animation).currentTime
);
if (now >= start) {
var p = t1 + delta * easing((now - start) / duration);
tick?.(p, 1 - p);
}
return t1 + delta * easing(time / duration);
};
return true;
});
}
if (tick) {
loop(() => {
if (animation.playState !== 'running') return false;
var t = get_t();
tick(t, 1 - t);
return true;
});
}
};
return {
abort: () => {
@ -451,23 +403,15 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
// This prevents memory leaks in Chromium
animation.effect = null;
}
task?.abort();
on_abort?.();
on_finish = undefined;
on_abort = undefined;
},
deactivate: () => {
on_finish = undefined;
on_abort = undefined;
on_finish = noop;
},
reset: () => {
if (t2 === 0) {
tick?.(1, 0);
}
},
t: (now) => {
var t = t1 + delta * easing((now - start) / duration);
return Math.min(1, Math.max(0, t));
}
t: () => get_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 */
reset: () => void;
/** 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> = (

@ -1,4 +1,6 @@
import { flushSync } from 'svelte';
import { raf as svelte_raf } from 'svelte/internal/client';
import { queue_micro_task } from '../src/internal/client/dom/task.js';
export const raf = {
animations: new Set(),
@ -23,6 +25,7 @@ export const raf = {
*/
function tick(time) {
raf.time = time;
flushSync();
for (const animation of raf.animations) {
animation._update();
}
@ -38,12 +41,16 @@ class Animation {
#offset = raf.time;
#finished = () => {};
#cancelled = () => {};
/** @type {Function} */
#onfinish = () => {};
/** @type {Function} */
#oncancel = () => {};
target;
currentTime = 0;
startTime = 0;
playState = 'running';
/**
* @param {HTMLElement} target
@ -53,24 +60,9 @@ class Animation {
constructor(target, keyframes, { duration, delay }) {
this.target = target;
this.#keyframes = keyframes;
this.#duration = duration;
this.#duration = Math.round(duration);
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();
}
@ -82,7 +74,7 @@ class Animation {
this.#apply_keyframe(target_frame);
if (this.currentTime >= this.#duration) {
this.#finished();
this.#onfinish();
raf.animations.delete(this);
}
}
@ -131,9 +123,31 @@ class Animation {
this.currentTime = null;
// @ts-ignore
this.startTime = null;
this.#cancelled();
this.playState = 'idle';
this.#oncancel();
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[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);
assert.equal(spans[0].foo, 0);
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[1].foo, 1);
assert.equal(spans[2].foo, 1);

@ -14,4 +14,4 @@
{#each things as thing, i}
<span out:foo="{{delay: i * 50}}">{thing}</span>
{/each}
{/each}

@ -17,4 +17,4 @@
<p class='then' transition:foo>{value}</p>
{:catch error}
<p class='catch' transition:foo>{error.message}</p>
{/await}
{/await}

@ -4,7 +4,7 @@
let foo_text;
let bar_text;
function foo(node, params) {
function foo(node, { duration = 100 }) {
foo_text = node.textContent;
return () => {
@ -13,7 +13,7 @@
}
return {
duration: 100,
duration,
tick: t => {
node.foo = t;
}
@ -21,7 +21,7 @@
};
}
function bar(node, params) {
function bar(node, { duration = 100 }) {
bar_text = node.textContent;
return () => {
@ -30,7 +30,7 @@
}
return {
duration: 100,
duration,
tick: t => {
node.foo = t;
}
@ -43,4 +43,4 @@
<div class="foo" in:foo>a</div>
{:else}
<div out:bar>b</div>
{/if}
{/if}

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

Loading…
Cancel
Save