Skip to content

Commit 3d354f0

Browse files
fix: ensure previous transitions are properly aborted (#12460)
* fix: ensure previous transitions are properly aborted - nullify options when the transition is aborted. This ensures a change in options is reflected the next time, else it would stick around indefinetly - abort previous intro (if exists) when new intro plays (same for outro) fixes #11372 * add a test --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 436cc99 commit 3d354f0

File tree

6 files changed

+170
-21
lines changed

6 files changed

+170
-21
lines changed

.changeset/wicked-carrots-explain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: ensure previous transitions are properly aborted

packages/svelte/src/internal/client/dom/elements/transitions.js

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,17 @@ export function animation(element, get_fn, get_params) {
9696
) {
9797
const options = get_fn()(this.element, { from, to }, get_params?.());
9898

99-
animation = animate(this.element, options, undefined, 1, () => {
100-
animation?.abort();
101-
animation = undefined;
102-
});
99+
animation = animate(
100+
this.element,
101+
options,
102+
undefined,
103+
1,
104+
() => {
105+
animation?.abort();
106+
animation = undefined;
107+
},
108+
undefined
109+
);
103110
}
104111
},
105112
fix() {
@@ -157,10 +164,11 @@ export function animation(element, get_fn, get_params) {
157164
export function transition(flags, element, get_fn, get_params) {
158165
var is_intro = (flags & TRANSITION_IN) !== 0;
159166
var is_outro = (flags & TRANSITION_OUT) !== 0;
167+
var is_both = is_intro && is_outro;
160168
var is_global = (flags & TRANSITION_GLOBAL) !== 0;
161169

162170
/** @type {'in' | 'out' | 'both'} */
163-
var direction = is_intro && is_outro ? 'both' : is_intro ? 'in' : 'out';
171+
var direction = is_both ? 'both' : is_intro ? 'in' : 'out';
164172

165173
/** @type {import('#client').AnimationConfig | ((opts: { direction: 'in' | 'out' }) => import('#client').AnimationConfig) | undefined} */
166174
var current_options;
@@ -191,27 +199,54 @@ export function transition(flags, element, get_fn, get_params) {
191199

192200
// abort the outro to prevent overlap with the intro
193201
outro?.abort();
202+
// abort previous intro (can happen if an element is intro'd, then outro'd, then intro'd again)
203+
intro?.abort();
194204

195205
if (is_intro) {
196206
dispatch_event(element, 'introstart');
197-
intro = animate(element, get_options(), outro, 1, () => {
198-
dispatch_event(element, 'introend');
199-
intro = current_options = undefined;
200-
});
207+
intro = animate(
208+
element,
209+
get_options(),
210+
outro,
211+
1,
212+
() => {
213+
dispatch_event(element, 'introend');
214+
intro = current_options = undefined;
215+
},
216+
is_both
217+
? undefined
218+
: () => {
219+
intro = current_options = undefined;
220+
}
221+
);
201222
} else {
202223
reset?.();
203224
}
204225
},
205226
out(fn) {
227+
// abort previous outro (can happen if an element is outro'd, then intro'd, then outro'd again)
228+
outro?.abort();
229+
206230
if (is_outro) {
207231
element.inert = true;
208232

209233
dispatch_event(element, 'outrostart');
210-
outro = animate(element, get_options(), intro, 0, () => {
211-
dispatch_event(element, 'outroend');
212-
outro = current_options = undefined;
213-
fn?.();
214-
});
234+
outro = animate(
235+
element,
236+
get_options(),
237+
intro,
238+
0,
239+
() => {
240+
dispatch_event(element, 'outroend');
241+
outro = current_options = undefined;
242+
fn?.();
243+
},
244+
is_both
245+
? undefined
246+
: () => {
247+
outro = current_options = undefined;
248+
}
249+
);
215250

216251
// TODO arguably the outro should never null itself out until _all_ outros for this effect have completed...
217252
// in that case we wouldn't need to store `reset` separately
@@ -263,10 +298,11 @@ export function transition(flags, element, get_fn, get_params) {
263298
* @param {import('#client').AnimationConfig | ((opts: { direction: 'in' | 'out' }) => import('#client').AnimationConfig)} options
264299
* @param {import('#client').Animation | undefined} counterpart The corresponding intro/outro to this outro/intro
265300
* @param {number} t2 The target `t` value — `1` for intro, `0` for outro
266-
* @param {(() => void) | undefined} callback
301+
* @param {(() => void) | undefined} on_finish Called after successfully completing the animation
302+
* @param {(() => void) | undefined} on_abort Called if the animation is aborted
267303
* @returns {import('#client').Animation}
268304
*/
269-
function animate(element, options, counterpart, t2, callback) {
305+
function animate(element, options, counterpart, t2, on_finish, on_abort) {
270306
var is_intro = t2 === 1;
271307

272308
if (is_function(options)) {
@@ -278,7 +314,7 @@ function animate(element, options, counterpart, t2, callback) {
278314

279315
queue_micro_task(() => {
280316
var o = options({ direction: is_intro ? 'in' : 'out' });
281-
a = animate(element, o, counterpart, t2, callback);
317+
a = animate(element, o, counterpart, t2, on_finish, on_abort);
282318
});
283319

284320
// ...but we want to do so without using `async`/`await` everywhere, so
@@ -294,7 +330,7 @@ function animate(element, options, counterpart, t2, callback) {
294330
counterpart?.deactivate();
295331

296332
if (!options?.duration) {
297-
callback?.();
333+
on_finish?.();
298334
return {
299335
abort: noop,
300336
deactivate: noop,
@@ -319,6 +355,7 @@ function animate(element, options, counterpart, t2, callback) {
319355
var task;
320356

321357
if (css) {
358+
// run after a micro task so that all transitions that are lining up and are about to run can correctly measure the DOM
322359
queue_micro_task(() => {
323360
// WAAPI
324361
var keyframes = [];
@@ -349,7 +386,7 @@ function animate(element, options, counterpart, t2, callback) {
349386

350387
animation.finished
351388
.then(() => {
352-
callback?.();
389+
on_finish?.();
353390

354391
if (t2 === 1) {
355392
animation.cancel();
@@ -376,7 +413,7 @@ function animate(element, options, counterpart, t2, callback) {
376413
task = loop((now) => {
377414
if (now >= end) {
378415
tick?.(t2, 1 - t2);
379-
callback?.();
416+
on_finish?.();
380417
return false;
381418
}
382419

@@ -393,9 +430,11 @@ function animate(element, options, counterpart, t2, callback) {
393430
abort: () => {
394431
animation?.cancel();
395432
task?.abort();
433+
on_abort?.();
396434
},
397435
deactivate: () => {
398-
callback = undefined;
436+
on_finish = undefined;
437+
on_abort = undefined;
399438
},
400439
reset: () => {
401440
if (t2 === 0) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
get props() {
5+
return { visible: false };
6+
},
7+
8+
test({ assert, component, target, raf, logs }) {
9+
component.visible = true;
10+
const span = /** @type {HTMLSpanElement & { foo: number }} */ (target.querySelector('span'));
11+
12+
raf.tick(50);
13+
assert.equal(span.foo, 0.5);
14+
15+
component.visible = false;
16+
assert.equal(span.foo, 0.5);
17+
18+
raf.tick(75);
19+
assert.equal(span.foo, 0.25);
20+
21+
component.visible = true;
22+
raf.tick(100);
23+
assert.equal(span.foo, 0.5);
24+
25+
assert.deepEqual(logs, ['transition']); // should only run once
26+
}
27+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script>
2+
export let visible;
3+
4+
function foo(node) {
5+
console.log('transition');
6+
7+
return {
8+
duration: 100,
9+
tick: (t) => {
10+
node.foo = t;
11+
}
12+
};
13+
}
14+
</script>
15+
16+
{#if visible}
17+
<span transition:foo>hello</span>
18+
{/if}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
get props() {
5+
return { visible: false };
6+
},
7+
8+
test({ assert, component, target, raf, logs }) {
9+
component.visible = true;
10+
const span = /** @type {HTMLSpanElement & { foo: number, bar: number }} */ (
11+
target.querySelector('span')
12+
);
13+
14+
raf.tick(50);
15+
assert.equal(span.foo, 0.5);
16+
17+
component.visible = false;
18+
assert.equal(span.foo, 0.5);
19+
20+
raf.tick(75);
21+
assert.equal(span.foo, 0.75);
22+
assert.equal(span.bar, 0.75);
23+
24+
component.visible = true;
25+
raf.tick(100);
26+
assert.equal(span.foo, 0.25);
27+
assert.equal(span.bar, 1);
28+
29+
assert.deepEqual(logs, ['in', 'out', 'in']);
30+
}
31+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script>
2+
export let visible;
3+
4+
function foo(node) {
5+
console.log('in');
6+
7+
return {
8+
duration: 100,
9+
tick: (t) => {
10+
node.foo = t;
11+
}
12+
};
13+
}
14+
15+
function bar(node) {
16+
console.log('out');
17+
18+
return {
19+
duration: 100,
20+
tick: (t) => {
21+
node.bar = t;
22+
}
23+
};
24+
}
25+
</script>
26+
27+
{#if visible}
28+
<span in:foo out:bar>hello</span>
29+
{/if}

0 commit comments

Comments
 (0)