perf: batch FLIP animation reads and writes

Split the per-item `fix()` step in the keyed `{#each}` animation manager
into three phases (`fix_size` reads only, `fix_position` writes only,
`fix_transform` reads the post-absolute rect and applies a compensating
transform). The caller in each.js runs three batched loops instead of one
combined loop, which cuts forced synchronous reflows from ~2N down to ~2
per destroy batch.

The visual result is unchanged: `transform = from - to` lands the element
at its captured `from` position regardless of when `to` is measured.

Measured ~+42% in real Chromium on a 45-item animate:flip destroy.
All 6006 runtime tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pull/18267/head
Mathias Picker 4 months ago
parent 65283bc13a
commit 103646e0f0

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: batch FLIP animation reads and writes to eliminate per-item layout thrash

@ -628,13 +628,15 @@ function reconcile(state, array, anchor, flags, get_key) {
var controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null;
if (is_animated) {
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();
}
// Three batched phases minimise layout flushes. With the per-item
// `fix()` body each iteration forced two reflows (one for the
// `getComputedStyle` read, one for the post-absolute
// `getBoundingClientRect` read); batching brings the whole batch
// down to ~2 flushes total.
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_size();
for (i = 0; i < destroy_length; i += 1) to_destroy[i].nodes?.a?.fix_position();
for (i = 0; i < destroy_length; i += 1) to_destroy[i].nodes?.a?.fix_transform();
}
pause_effects(state, to_destroy, controlled_anchor);

@ -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 `fix_size`, consumed by `fix_position`. */
var frozen_width = '';
/** Captured pre-absolute size, set by `fix_size`, consumed by `fix_position`. */
var frozen_height = '';
nodes.a ??= {
element,
@ -128,41 +132,57 @@ export function animation(element, get_fn, get_params) {
);
}
},
fix() {
fix_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;
},
fix_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;
},
fix_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;

@ -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).
*/
fix_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.
*/
fix_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.
*/
fix_transform: () => void;
/** Unfix the element position if the outro is aborted */
unfix: () => void;
}

Loading…
Cancel
Save