diff --git a/.changeset/moody-lamps-slide.md b/.changeset/moody-lamps-slide.md new file mode 100644 index 0000000000..5832f12e42 --- /dev/null +++ b/.changeset/moody-lamps-slide.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: measure nested transitions before applying their starting styles diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index 7c4d385807..67b177841a 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -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,99 +381,112 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) { const { delay = 0, css, tick, easing = linear } = options; - var keyframes = []; + /** @type {globalThis.Animation} */ + var animation; - if (is_intro && counterpart === undefined) { - if (tick) { - tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes? - } + var get_t = () => 1 - t2; - if (css) { - var styles = css_to_keyframe(css(0, 1)); - keyframes.push(styles, styles); - } - } + // 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 get_t = () => 1 - t2; + var keyframes = []; - // 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' }); + if (is_intro && counterpart === undefined) { + if (tick) { + tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes? + } - animation.onfinish = () => { - // remove dummy animation from the stack to prevent conflict with main animation - animation.cancel(); + if (css) { + var styles = css_to_keyframe(css(0, 1)); + keyframes.push(styles, styles); + } + } - on_begin(); + // 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 + animation = element.animate(keyframes, { duration: delay, fill: 'forwards' }); - // for bidirectional transitions, we start from the current position, - // rather than doing a full intro/outro - var t1 = counterpart?.t() ?? 1 - t2; - counterpart?.abort(); + animation.onfinish = () => { + // remove dummy animation from the stack to prevent conflict with main animation + animation.cancel(); - var delta = t2 - t1; - var duration = /** @type {number} */ (options.duration) * Math.abs(delta); - var keyframes = []; + on_begin(); - if (duration > 0) { - /** - * Whether or not the CSS includes `overflow: hidden`, in which case we need to - * add it as an inline style to work around a Safari <18 bug - * TODO 6.0 remove this, if possible - */ - var needs_overflow_hidden = false; + // for bidirectional transitions, we start from the current position, + // rather than doing a full intro/outro + var t1 = counterpart?.t() ?? 1 - t2; + counterpart?.abort(); - if (css) { - var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value + var delta = t2 - t1; + var duration = /** @type {number} */ (options.duration) * Math.abs(delta); + var keyframes = []; + + if (duration > 0) { + /** + * Whether or not the CSS includes `overflow: hidden`, in which case we need to + * add it as an inline style to work around a Safari <18 bug + * TODO 6.0 remove this, if possible + */ + var needs_overflow_hidden = false; - for (var i = 0; i <= n; i += 1) { - var t = t1 + delta * easing(i / n); - var styles = css_to_keyframe(css(t, 1 - t)); - keyframes.push(styles); + if (css) { + var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value - needs_overflow_hidden ||= styles.overflow === 'hidden'; + for (var i = 0; i <= n; i += 1) { + var t = t1 + delta * easing(i / n); + var styles = css_to_keyframe(css(t, 1 - t)); + keyframes.push(styles); + + needs_overflow_hidden ||= styles.overflow === 'hidden'; + } } - } - if (needs_overflow_hidden) { - /** @type {HTMLElement} */ (element).style.overflow = 'hidden'; - } + if (needs_overflow_hidden) { + /** @type {HTMLElement} */ (element).style.overflow = 'hidden'; + } - get_t = () => { - var time = /** @type {number} */ ( - /** @type {globalThis.Animation} */ (animation).currentTime - ); + get_t = () => { + var time = /** @type {number} */ ( + /** @type {globalThis.Animation} */ (animation).currentTime + ); - return t1 + delta * easing(time / duration); - }; + return t1 + delta * easing(time / duration); + }; - if (tick) { - loop(() => { - if (animation.playState !== 'running') return false; + if (tick) { + loop(() => { + if (animation.playState !== 'running') return false; - var t = get_t(); - tick(t, 1 - t); + var t = get_t(); + tick(t, 1 - t); - return true; - }); + return true; + }); + } } - } - animation = element.animate(keyframes, { duration, fill: 'forwards' }); + animation = element.animate(keyframes, { duration, fill: 'forwards' }); - animation.onfinish = () => { - get_t = () => t2; - tick?.(t2, 1 - t2); - on_finish(); + animation.onfinish = () => { + get_t = () => t2; + tick?.(t2, 1 - t2); + on_finish(); + }; }; - }; + }); return { abort: () => { + aborted = true; + if (animation) { animation.cancel(); // This prevents memory leaks in Chromium diff --git a/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/Nested.svelte b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/Nested.svelte new file mode 100644 index 0000000000..eeb325059f --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/transition-nested-measure/Nested.svelte @@ -0,0 +1,14 @@ + + +