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; var controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null;
if (is_animated) { if (is_animated) {
for (i = 0; i < destroy_length; i += 1) { // Three batched phases minimise layout flushes. With the per-item
to_destroy[i].nodes?.a?.measure(); // `fix()` body each iteration forced two reflows (one for the
} // `getComputedStyle` read, one for the post-absolute
// `getBoundingClientRect` read); batching brings the whole batch
for (i = 0; i < destroy_length; i += 1) { // down to ~2 flushes total.
to_destroy[i].nodes?.a?.fix(); 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); 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 }} */ /** @type {null | { position: string, width: string, height: string, transform: string }} */
var original_styles = null; 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 ??= { nodes.a ??= {
element, element,
@ -128,19 +132,24 @@ 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, // 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, // 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(...)` // 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. // 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, // 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. // and changing the style to 'absolute' can for example influence the width. We also have
var { position, width, height } = getComputedStyle(element); // 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;
if (position !== 'absolute' && position !== 'fixed') { var style = /** @type {HTMLElement | SVGElement} */ (this.element).style;
var style = /** @type {HTMLElement | SVGElement} */ (element).style;
original_styles = { original_styles = {
position: style.position, position: style.position,
@ -148,21 +157,32 @@ export function animation(element, get_fn, get_params) {
height: style.height, height: style.height,
transform: style.transform 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.position = 'absolute';
style.width = width; style.width = frozen_width;
style.height = height; style.height = frozen_height;
var to = element.getBoundingClientRect(); },
fix_transform() {
if (original_styles === null) return;
var to = this.element.getBoundingClientRect();
if (from.left !== to.left || from.top !== to.top) { if (from.left !== to.left || from.top !== to.top) {
var transform = `translate(${from.left - to.left}px, ${from.top - to.top}px)`; var style = /** @type {HTMLElement | SVGElement} */ (this.element).style;
style.transform = style.transform ? `${style.transform} ${transform}` : transform; var translate = `translate(${from.left - to.left}px, ${from.top - to.top}px)`;
} style.transform = style.transform ? `${style.transform} ${translate}` : translate;
} }
}, },
unfix() { unfix() {
if (original_styles) { if (original_styles) {
var style = /** @type {HTMLElement | SVGElement} */ (element).style; var style = /** @type {HTMLElement | SVGElement} */ (this.element).style;
style.position = original_styles.position; style.position = original_styles.position;
style.width = original_styles.width; style.width = original_styles.width;

@ -126,8 +126,25 @@ export interface AnimationManager {
measure: () => void; measure: () => void;
/** Called during keyed each block reconciliation, after updates — this triggers the animation */ /** Called during keyed each block reconciliation, after updates — this triggers the animation */
apply: () => void; 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 the element position if the outro is aborted */
unfix: () => void; unfix: () => void;
} }

Loading…
Cancel
Save