Skip to content

fix: prevent animation keyframes being deleted before animation execution end #9126

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 9 additions & 4 deletions packages/svelte/src/runtime/internal/animations.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,31 @@ export function create_animation(node, from, fn, params) {
} = fn(node, { from, to }, params);
let running = true;
let started = false;
let name;
let animation_name;
/** @type {import("./private.js").AnimationInformation | undefined} */
let css_animation_info = undefined;
/** @returns {void} */
function start() {
if (css) {
name = create_rule(node, 0, 1, duration, delay, easing, css);
css_animation_info = create_rule(node, 0, 1, duration, delay, easing, css);
animation_name = css_animation_info.name;
}
if (!delay) {
started = true;
}
}
/** @returns {void} */
function stop() {
if (css) delete_rule(node, name);
if (css) delete_rule(node, animation_name);
running = false;
}
loop((now) => {
if (!started && now >= start_time) {
started = true;
}
if (started && now >= end) {
const css_animation_state = css_animation_info?.animation?.playState;
const is_css_animation_finished = !css_animation_state || css_animation_state === 'finished';
if (started && now >= end && is_css_animation_finished) {
tick(1, 0);
stop();
}
Expand Down
6 changes: 6 additions & 0 deletions packages/svelte/src/runtime/internal/private.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export type AnimationFn = (
params: any
) => AnimationConfig;

export interface AnimationInformation {
name: string;
/** is not valorized in Node environment */
animation?: CSSAnimation;
}

export type Listener = (entry: ResizeObserverEntry) => any;

//todo: documentation says it is DOMRect, but in IE it would be ClientRect
Expand Down
35 changes: 26 additions & 9 deletions packages/svelte/src/runtime/internal/style_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function create_style_information(doc, node) {
* @param {(t: number) => number} ease
* @param {(t: number, u: number) => string} fn
* @param {number} uid
* @returns {string}
* @returns {import("./private.d.ts").AnimationInformation}
*/
export function create_rule(node, a, b, duration, delay, ease, fn, uid = 0) {
const step = 16.666 / duration;
Expand All @@ -50,19 +50,36 @@ export function create_rule(node, a, b, duration, delay, ease, fn, uid = 0) {
keyframes += p * 100 + `%{${fn(t, 1 - t)}}\n`;
}
const rule = keyframes + `100% {${fn(b, 1 - b)}}\n}`;
const name = `__svelte_${hash(rule)}_${uid}`;
const keyframe_name = `__svelte_${hash(rule)}_${uid}`;
const doc = get_root_for_style(node);
const { stylesheet, rules } = managed_styles.get(doc) || create_style_information(doc, node);
if (!rules[name]) {
rules[name] = true;
stylesheet.insertRule(`@keyframes ${name} ${rule}`, stylesheet.cssRules.length);
if (!rules[keyframe_name]) {
rules[keyframe_name] = true;
stylesheet.insertRule(`@keyframes ${keyframe_name} ${rule}`, stylesheet.cssRules.length);
}
const animation = node.style.animation || '';
const current_animation_style = node.style.animation || '';
node.style.animation = `${
animation ? `${animation}, ` : ''
}${name} ${duration}ms linear ${delay}ms 1 both`;
current_animation_style ? `${current_animation_style}, ` : ''
}${keyframe_name} ${duration}ms linear ${delay}ms 1 both`;

/** @type {CSSAnimation[] | undefined} */
const all_animations = /** @type {any} */ (node.getAnimations?.()); // `getAnimations` is not supported in Node environment
/** @type {import("./private.d.ts").AnimationInformation} */
let animation_info = {
name: keyframe_name
};
if (all_animations) {
for (const a of all_animations) {
// TODO: perhaps a map may avoid unnecessary iterations
if (a.animationName === keyframe_name) {
animation_info.animation = a;
break;
}
}
}

active += 1;
return name;
return animation_info;
}

/**
Expand Down
40 changes: 33 additions & 7 deletions packages/svelte/src/runtime/internal/transitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,14 @@ export function create_in_transition(node, fn, params) {
tick = noop,
css
} = config || null_transition;
if (css) animation_name = create_rule(node, 0, 1, duration, delay, easing, css, uid++);

/** @type {import("./private.js").AnimationInformation | undefined} */
let css_animation_info = undefined;
if (css) {
css_animation_info = create_rule(node, 0, 1, duration, delay, easing, css, uid++);
animation_name = css_animation_info.name;
}

tick(0, 1);
const start_time = now() + delay;
const end_time = start_time + duration;
Expand All @@ -141,7 +148,10 @@ export function create_in_transition(node, fn, params) {
add_render_callback(() => dispatch(node, true, 'start'));
task = loop((now) => {
if (running) {
if (now >= end_time) {
const css_animation_state = css_animation_info?.animation?.playState;
const is_css_animation_finished =
!css_animation_state || css_animation_state === 'finished';
if (now >= end_time && is_css_animation_finished) {
tick(1, 0);
dispatch(node, true, 'end');
cleanup();
Expand Down Expand Up @@ -208,7 +218,12 @@ export function create_out_transition(node, fn, params) {
css
} = config || null_transition;

if (css) animation_name = create_rule(node, 1, 0, duration, delay, easing, css);
/** @type {import("./private.js").AnimationInformation | undefined} */
let css_animation_info = undefined;
if (css) {
css_animation_info = create_rule(node, 1, 0, duration, delay, easing, css);
animation_name = css_animation_info.name;
}

const start_time = now() + delay;
const end_time = start_time + duration;
Expand All @@ -221,7 +236,10 @@ export function create_out_transition(node, fn, params) {

loop((now) => {
if (running) {
if (now >= end_time) {
const css_animation_state = css_animation_info?.animation?.playState;
const is_css_animation_finished =
!css_animation_state || css_animation_state === 'finished';
if (now >= end_time && is_css_animation_finished) {
tick(0, 1);
dispatch(node, false, 'end');
if (!--group.r) {
Expand Down Expand Up @@ -358,23 +376,27 @@ export function create_bidirectional_transition(node, fn, params, intro) {
if (running_program || pending_program) {
pending_program = program;
} else {
/** @type {import("./private.js").AnimationInformation | undefined} */
let css_animation_info = undefined;
// if this is an intro, and there's a delay, we need to do
// an initial tick and/or apply CSS animation immediately
if (css) {
clear_animation();
animation_name = create_rule(node, t, b, duration, delay, easing, css);
css_animation_info = create_rule(node, t, b, duration, delay, easing, css);
animation_name = css_animation_info.name;
}
if (b) tick(0, 1);
running_program = init(program, duration);
add_render_callback(() => dispatch(node, b, 'start'));

loop((now) => {
if (pending_program && now > pending_program.start) {
running_program = init(pending_program, duration);
pending_program = null;
dispatch(node, running_program.b, 'start');
if (css) {
clear_animation();
animation_name = create_rule(
css_animation_info = create_rule(
node,
t,
running_program.b,
Expand All @@ -383,10 +405,14 @@ export function create_bidirectional_transition(node, fn, params, intro) {
easing,
config.css
);
animation_name = css_animation_info.name;
}
}
if (running_program) {
if (now >= running_program.end) {
const css_animation_state = css_animation_info?.animation?.playState;
const is_css_animation_finished =
!css_animation_state || css_animation_state === 'finished';
if (now >= running_program.end && is_css_animation_finished) {
tick((t = running_program.b), 1 - t);
dispatch(node, running_program.b, 'end');
if (!pending_program) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
export default {
props: {
things: [
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
{ id: 4, name: 'd' },
{ id: 5, name: 'e' }
]
},

html: `
<div>a</div>
<div>b</div>
<div>c</div>
<div>d</div>
<div>e</div>
`,

async test({ assert, component, target, waitUntil }) {
let divs = target.querySelectorAll('div');
divs.forEach((div) => {
div.getBoundingClientRect = function () {
const index = [...this.parentNode.children].indexOf(this);
const top = index * 30;

return {
left: 0,
right: 100,
top,
bottom: top + 20
};
};
});

component.things = [
{ id: 5, name: 'e' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
{ id: 4, name: 'd' },
{ id: 1, name: 'a' }
];

divs = target.querySelectorAll('div');
assert.ok(~divs[0].style.animation.indexOf('__svelte'));
assert.equal(divs[1].style.animation, '');
assert.equal(divs[2].style.animation, '');
assert.equal(divs[3].style.animation, '');
assert.ok(~divs[4].style.animation.indexOf('__svelte'));
assert.equal(divs[0].getAnimations().length, 1);
assert.equal(divs[1].getAnimations().length, 0);
assert.equal(divs[2].getAnimations().length, 0);
assert.equal(divs[3].getAnimations().length, 0);
assert.equal(divs[4].getAnimations().length, 1);

const animations = [divs[0].getAnimations().at(0), divs[4].getAnimations().at(0)];

animations.forEach((animation) => {
assert.equal(animation.playState, 'running');
});

await Promise.all(animations.map((animation) => animation.finished)).catch((e) => {
if (e.name === 'AbortError') {
throw new Error(
'The animation was aborted, keyframes have been removed before the animation execution end.'
);
}
throw e;
});
assert.ok(~divs[0].style.animation.indexOf('__svelte'));
assert.equal(divs[1].style.animation, '');
assert.equal(divs[2].style.animation, '');
assert.equal(divs[3].style.animation, '');
assert.ok(~divs[4].style.animation.indexOf('__svelte'));
assert.equal(divs[0].getAnimations().length, 1);
assert.equal(divs[1].getAnimations().length, 0);
assert.equal(divs[2].getAnimations().length, 0);
assert.equal(divs[3].getAnimations().length, 0);
assert.equal(divs[4].getAnimations().length, 1);
animations.forEach((animation) => {
assert.equal(animation.playState, 'finished');
});

await waitUntil(() => !divs[4].style.animation);
assert.ok(~divs[0].style.animation, '');
assert.equal(divs[1].style.animation, '');
assert.equal(divs[2].style.animation, '');
assert.equal(divs[3].style.animation, '');
assert.ok(~divs[4].style.animation, '');
assert.equal(divs[0].getAnimations().length, 0);
assert.equal(divs[1].getAnimations().length, 0);
assert.equal(divs[2].getAnimations().length, 0);
assert.equal(divs[3].getAnimations().length, 0);
assert.equal(divs[4].getAnimations().length, 0);
animations.forEach((animation) => {
assert.equal(animation.playState, 'idle');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script>
export let things;

function flip(_node, animation, _params) {
const dx = animation.from.left - animation.to.left;
const dy = animation.from.top - animation.to.top;

return {
duration: 100,
css: (_t, u) => `transform: translate(${u + dx}px, ${u * dy}px)`
};
}
</script>

{#each things as thing (thing.id)}
<div animate:flip>{thing.name}</div>
{/each}
Loading