fix: measure nested transitions before applying their starting styles (#18647)

Fixes #18421 (regression from #16035, which re-broke the #4784
scenario).

Since #16035 the dummy delay animation runs with `fill: 'forwards'`, so
an intro's `t = 0` styles (for `slide`, `height: 0`) apply the moment
the transition is created and stay applied until the real animation
starts a frame later. Transition functions of other elements in the same
batch run after that and measure a collapsed DOM. With nested intros
(the TreeView from the issue), intro effects run deepest-first, so every
ancestor's `slide` captures a height where its children are pinned at 0.
On the issue's REPL every `<ul>` animates `0px -> 36px` (the height of a
single row) and then jumps to its real size when the animation finishes,
which is the "children pop out" behavior from #4784.

The fix waits one microtask before applying the initial styles and
creating the dummy, the same way deferred transitions (`crossfade`)
already work in `animate()`. Everything in the batch measures the DOM
first; the microtask still runs before the next paint, so elements never
render unstyled and the #14732 behavior is preserved. Verified in a real
browser that a delayed intro (`delay: 300`) stays held at `height: 0`
for the whole delay, including the first painted frame.

Most of the diff is the existing dummy/onfinish block moving into
`queue_micro_task`; hiding whitespace shows the actual change.
pull/18721/head
Khaled Waleed 4 days ago committed by GitHub
parent c7d8233a5c
commit 955b701df2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: measure nested transitions before applying their starting styles

@ -337,6 +337,7 @@ export function transition(flags, element, get_fn, get_params) {
*/
function animate(element, options, counterpart, t2, on_begin, on_finish) {
var is_intro = t2 === 1;
var aborted = false;
if (is_function(options)) {
// In the case of a deferred transition (such as `crossfade`), `option` will be
@ -344,7 +345,6 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
// once the DOM has been updated...
/** @type {Animation} */
var a;
var aborted = false;
queue_micro_task(() => {
if (aborted) return;
@ -381,6 +381,18 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
const { delay = 0, css, tick, easing = linear } = options;
/** @type {globalThis.Animation} */
var animation;
var get_t = () => 1 - t2;
// wait a microtask before applying the initial styles and creating the dummy animation,
// so that transitions created in the same batch (e.g. on nested elements) all measure
// the DOM first (#18421). this still happens before the next paint, so the element
// won't be rendered without styles applied (#14732)
queue_micro_task(() => {
if (aborted) return;
var keyframes = [];
if (is_intro && counterpart === undefined) {
@ -394,15 +406,13 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
}
}
var get_t = () => 1 - t2;
// 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
//
// fill forwards to prevent the element from rendering without styles applied
// see https://github.com/sveltejs/svelte/issues/14732
var animation = element.animate(keyframes, { duration: delay, fill: 'forwards' });
animation = element.animate(keyframes, { duration: delay, fill: 'forwards' });
animation.onfinish = () => {
// remove dummy animation from the stack to prevent conflict with main animation
@ -471,9 +481,12 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
on_finish();
};
};
});
return {
abort: () => {
aborted = true;
if (animation) {
animation.cancel();
// This prevents memory leaks in Chromium

@ -0,0 +1,14 @@
<script>
import Nested from './Nested.svelte';
import { slide } from 'svelte/transition';
let { depth } = $props();
</script>
<div class="level-{depth}" in:slide|global={{ duration: 100 }}>
{#if depth > 0}
<Nested depth={depth - 1} />
{:else}
<div style="height: 100px">leaf</div>
{/if}
</div>

@ -0,0 +1,36 @@
import { test } from '../../assert';
export default test({
async test({ assert, target }) {
const button = target.querySelector('button');
button?.click();
// wait for the transition's keyframes to be created
const animation = await new Promise((resolve, reject) => {
const start = performance.now();
function check() {
const outer = target.querySelector('.level-2');
const animation = outer
?.getAnimations()
.find((a) => a.effect?.getTiming().duration === 100);
if (animation) {
resolve(animation);
} else if (performance.now() - start > 2000) {
reject(new Error('timed out waiting for the transition to start'));
} else {
requestAnimationFrame(check);
}
}
check();
});
// the outermost `slide` must have measured the element with its
// descendants at their natural size, not collapsed to zero by their
// own starting styles (#18421)
const keyframes = animation.effect?.getKeyframes() ?? [];
assert.equal(keyframes[keyframes.length - 1].height, '100px');
}
});

@ -0,0 +1,11 @@
<script>
import Nested from './Nested.svelte';
let visible = $state(false);
</script>
<button onclick={() => (visible = !visible)}>toggle</button>
{#if visible}
<Nested depth={2} />
{/if}
Loading…
Cancel
Save