Skip to content

fix: use WAAPI to control timing of JS-based animations #13018

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 31 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d083a3d
fix: always call tick() if defined in transitions
rChaoz Aug 16, 2024
b488253
ensure on_finish is only called once
Rich-Harris Aug 23, 2024
f2766b5
actually this is better
Rich-Harris Aug 23, 2024
6bbc386
add failing test
Rich-Harris Aug 23, 2024
a3607db
WIP
Rich-Harris Aug 25, 2024
e6a02e5
WIP
Rich-Harris Aug 25, 2024
83d5fc9
simplify
Rich-Harris Aug 25, 2024
31d81a6
move some stuff around
Rich-Harris Aug 25, 2024
2817255
simplify
Rich-Harris Aug 25, 2024
28611e0
simplify
Rich-Harris Aug 25, 2024
fcde067
Merge branch 'main' into waapi
Rich-Harris Aug 25, 2024
63b3e6b
use separate animation for delay, so we can insert an on_start callback
Rich-Harris Aug 25, 2024
d0e2512
WIP
Rich-Harris Aug 25, 2024
9515c78
shuffle things around a bit
Rich-Harris Aug 26, 2024
47cd44b
Merge branch 'main' into waapi
Rich-Harris Aug 26, 2024
1bd3abb
kludgy fix to float shenanigans
Rich-Harris Aug 26, 2024
572c085
almost working
Rich-Harris Aug 26, 2024
bc2c5f0
ALL TESTS PASSING WOOOO
Rich-Harris Aug 26, 2024
7353b92
lint
Rich-Harris Aug 26, 2024
d9a17c8
simplify
Rich-Harris Aug 26, 2024
8406b95
simplify
Rich-Harris Aug 26, 2024
35f9315
Merge branch 'main' into waapi
Rich-Harris Aug 26, 2024
f0cd9ec
simplify
Rich-Harris Aug 26, 2024
7d270b3
never abort bidirectional transitions
Rich-Harris Aug 27, 2024
93dd77c
tweak
Rich-Harris Aug 27, 2024
db26a56
unused
Rich-Harris Aug 27, 2024
0e4d82d
drive-by, fix #13019
Rich-Harris Aug 27, 2024
7690293
Update packages/svelte/src/internal/client/dom/elements/transitions.js
Rich-Harris Aug 27, 2024
1e06882
Update packages/svelte/src/internal/client/dom/elements/transitions.js
Rich-Harris Aug 27, 2024
3cf264b
changeset
Rich-Harris Aug 27, 2024
51e3342
changeset
Rich-Harris Aug 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading