Skip to content

Commit

Permalink
fix: use WAAPI to control timing of JS-based animations (#13018)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
3 people authored Aug 27, 2024
1 parent 93ffb4d commit e73e63e
Show file tree
Hide file tree
Showing 12 changed files with 211 additions and 189 deletions.
5 changes: 5 additions & 0 deletions .changeset/five-shirts-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

fix: use WAAPI to control timing of JS-based animations
5 changes: 5 additions & 0 deletions .changeset/slimy-news-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

fix: never abort bidirectional transitions
256 changes: 100 additions & 156 deletions packages/svelte/src/internal/client/dom/elements/transitions.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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} */
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -311,25 +280,24 @@ 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;

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
Expand All @@ -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,
Expand All @@ -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: () => {
Expand All @@ -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()
};
}
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = (
Expand Down
Loading

0 comments on commit e73e63e

Please sign in to comment.