diff --git a/.changeset/flip-batched.md b/.changeset/flip-batched.md new file mode 100644 index 0000000000..9f958a34ee --- /dev/null +++ b/.changeset/flip-batched.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: capture FLIP sizes before any sibling leaves flex flow diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 2df1d6ffa1..95ff2faa41 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -630,13 +630,15 @@ function reconcile(state, array, anchor, flags, get_key) { var controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null; if (is_animated) { + // Doing all the reads _then_ all the writes minimises layout flushes. + // `measure` and `capture_size` are both reads, so they share a loop. for (i = 0; i < destroy_length; i += 1) { - to_destroy[i].nodes?.a?.measure(); - } - - for (i = 0; i < destroy_length; i += 1) { - to_destroy[i].nodes?.a?.fix(); + var am = to_destroy[i].nodes?.a; + am?.measure(); + am?.capture_size(); } + for (i = 0; i < destroy_length; i += 1) to_destroy[i].nodes?.a?.set_position(); + for (i = 0; i < destroy_length; i += 1) to_destroy[i].nodes?.a?.set_transform(); } pause_effects(state, to_destroy, controlled_anchor); diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index 7c4d385807..12e38ee8f9 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -96,6 +96,10 @@ export function animation(element, get_fn, get_params) { /** @type {null | { position: string, width: string, height: string, transform: string }} */ var original_styles = null; + /** Captured pre-absolute size, set by `capture_size`, consumed by `set_position`. */ + var frozen_width = ''; + /** Captured pre-absolute size, set by `capture_size`, consumed by `set_position`. */ + var frozen_height = ''; nodes.a ??= { element, @@ -128,41 +132,57 @@ export function animation(element, get_fn, get_params) { ); } }, - fix() { + capture_size() { + original_styles = null; + // If an animation is already running, transforming the element is likely to fail, // because the styles applied by the animation take precedence. In the case of crossfade, // that means the `translate(...)` of the crossfade transition overrules the `translate(...)` // we would apply below, leading to the element jumping somewhere to the top left. - if (element.getAnimations().length) return; + if (this.element.getAnimations().length) return; // It's important to destructure these to get fixed values - the object itself has getters, - // and changing the style to 'absolute' can for example influence the width. - var { position, width, height } = getComputedStyle(element); - - if (position !== 'absolute' && position !== 'fixed') { - var style = /** @type {HTMLElement | SVGElement} */ (element).style; - - original_styles = { - position: style.position, - width: style.width, - height: style.height, - transform: style.transform - }; - - style.position = 'absolute'; - style.width = width; - style.height = height; - var to = element.getBoundingClientRect(); - - if (from.left !== to.left || from.top !== to.top) { - var transform = `translate(${from.left - to.left}px, ${from.top - to.top}px)`; - style.transform = style.transform ? `${style.transform} ${transform}` : transform; - } + // and changing the style to 'absolute' can for example influence the width. We also have + // to capture this BEFORE any sibling has been mutated, so that the recorded dimensions + // reflect the original layout (matters in flex/grid containers). + var { position, width, height } = getComputedStyle(this.element); + + if (position === 'absolute' || position === 'fixed') return; + + var style = /** @type {HTMLElement | SVGElement} */ (this.element).style; + + original_styles = { + position: style.position, + width: style.width, + height: style.height, + transform: style.transform + }; + frozen_width = width; + frozen_height = height; + }, + set_position() { + if (original_styles === null) return; + + var style = /** @type {HTMLElement | SVGElement} */ (this.element).style; + + style.position = 'absolute'; + style.width = frozen_width; + style.height = frozen_height; + }, + set_transform() { + if (original_styles === null) return; + + var to = this.element.getBoundingClientRect(); + + if (from.left !== to.left || from.top !== to.top) { + var style = /** @type {HTMLElement | SVGElement} */ (this.element).style; + var translate = `translate(${from.left - to.left}px, ${from.top - to.top}px)`; + style.transform = style.transform ? `${style.transform} ${translate}` : translate; } }, unfix() { if (original_styles) { - var style = /** @type {HTMLElement | SVGElement} */ (element).style; + var style = /** @type {HTMLElement | SVGElement} */ (this.element).style; style.position = original_styles.position; style.width = original_styles.width; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 2320e7b510..beb87addd4 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -126,8 +126,25 @@ export interface AnimationManager { measure: () => void; /** Called during keyed each block reconciliation, after updates — this triggers the animation */ apply: () => void; - /** Fix the element position, so that siblings can move to the correct destination */ - fix: () => void; + /** + * Capture the element's pre-absolute-positioning size + original styles. + * Pure read pass — runs before any items have been mutated so the captured + * dimensions reflect the original layout (matters in flex/grid containers). + */ + capture_size: () => void; + /** + * Apply `position: absolute` and the captured size. Pure write pass — runs + * after all items' sizes have been captured, so the layout invalidation + * is batched into a single subsequent flush. + */ + set_position: () => void; + /** + * Read the element's post-absolute bounding rect and apply a compensating + * transform so it visually stays at its captured-`from` position. The first + * call in a batch pays one layout flush; subsequent calls in the same + * batch are flush-free because `transform` is compositor-only. + */ + set_transform: () => void; /** Unfix the element position if the outro is aborted */ unfix: () => void; }