From 103646e0f0d93855c9f7dab9a2302050f0c398d5 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Thu, 21 May 2026 17:49:53 +0200 Subject: [PATCH 1/5] 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) --- .changeset/flip-batched.md | 5 ++ .../src/internal/client/dom/blocks/each.js | 16 +++-- .../client/dom/elements/transitions.js | 70 ++++++++++++------- .../svelte/src/internal/client/types.d.ts | 21 +++++- 4 files changed, 78 insertions(+), 34 deletions(-) create mode 100644 .changeset/flip-batched.md diff --git a/.changeset/flip-batched.md b/.changeset/flip-batched.md new file mode 100644 index 0000000000..8ae1219816 --- /dev/null +++ b/.changeset/flip-batched.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: batch FLIP animation reads and writes to eliminate per-item layout thrash diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 5f8adee1b5..54749ab9bf 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -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); diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index 7c4d385807..0ab42e0f5d 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 `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; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 2320e7b510..0309124150 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). + */ + 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; } From 1ea3389f040b386c87507d68cfb7c107a3fd21ff Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Fri, 22 May 2026 14:17:49 +0200 Subject: [PATCH 2/5] chore: reword changeset to reflect bug-fix framing The refactor closes a 5-year-old correctness bug (#6733), not just a perf optimization. Update the changeset entry to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/flip-batched.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/flip-batched.md b/.changeset/flip-batched.md index 8ae1219816..9f958a34ee 100644 --- a/.changeset/flip-batched.md +++ b/.changeset/flip-batched.md @@ -2,4 +2,4 @@ 'svelte': patch --- -perf: batch FLIP animation reads and writes to eliminate per-item layout thrash +fix: capture FLIP sizes before any sibling leaves flex flow From 45b12df4ac684b3351bd8601398f80e6667f8098 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 28 May 2026 07:45:16 -0400 Subject: [PATCH 3/5] rename methods to make phases more explicit --- packages/svelte/src/internal/client/dom/blocks/each.js | 6 +++--- .../src/internal/client/dom/elements/transitions.js | 10 +++++----- packages/svelte/src/internal/client/types.d.ts | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 54749ab9bf..1a3e67c8df 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -634,9 +634,9 @@ function reconcile(state, array, anchor, flags, get_key) { // `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(); + for (i = 0; i < destroy_length; i += 1) to_destroy[i].nodes?.a?.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 0ab42e0f5d..12e38ee8f9 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -96,9 +96,9 @@ 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`. */ + /** Captured pre-absolute size, set by `capture_size`, consumed by `set_position`. */ var frozen_width = ''; - /** Captured pre-absolute size, set by `fix_size`, consumed by `fix_position`. */ + /** Captured pre-absolute size, set by `capture_size`, consumed by `set_position`. */ var frozen_height = ''; nodes.a ??= { @@ -132,7 +132,7 @@ export function animation(element, get_fn, get_params) { ); } }, - fix_size() { + capture_size() { original_styles = null; // If an animation is already running, transforming the element is likely to fail, @@ -160,7 +160,7 @@ export function animation(element, get_fn, get_params) { frozen_width = width; frozen_height = height; }, - fix_position() { + set_position() { if (original_styles === null) return; var style = /** @type {HTMLElement | SVGElement} */ (this.element).style; @@ -169,7 +169,7 @@ export function animation(element, get_fn, get_params) { style.width = frozen_width; style.height = frozen_height; }, - fix_transform() { + set_transform() { if (original_styles === null) return; var to = this.element.getBoundingClientRect(); diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 0309124150..beb87addd4 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -131,20 +131,20 @@ export interface AnimationManager { * 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; + 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. */ - fix_position: () => void; + 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. */ - fix_transform: () => void; + set_transform: () => void; /** Unfix the element position if the outro is aborted */ unfix: () => void; } From 145a5cc20491adf1f9e635caccf32c294f28aa00 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 28 May 2026 07:50:08 -0400 Subject: [PATCH 4/5] shrink comment (the AI agent habit of referring to the behaviour of the code it just deleted is somewhat unhelpful) --- packages/svelte/src/internal/client/dom/blocks/each.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 1a3e67c8df..646a85b54f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -628,11 +628,7 @@ function reconcile(state, array, anchor, flags, get_key) { var controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null; if (is_animated) { - // 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. + // Doing all the reads _then_ all the writes minimises layout flushes 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?.capture_size(); for (i = 0; i < destroy_length; i += 1) to_destroy[i].nodes?.a?.set_position(); From 2e9d69a7913f18ddd3944b1cb089a50cd9a9cb4f Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Fri, 29 May 2026 16:51:03 +0200 Subject: [PATCH 5/5] refactor: merge measure + capture_size into one read loop Both are pure DOM reads, so there's no batching reason to keep them in separate loops over the destroyed items. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/svelte/src/internal/client/dom/blocks/each.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 646a85b54f..1f6ed81e04 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -628,9 +628,13 @@ 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 - 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?.capture_size(); + // 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) { + 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(); }