From 7c321a478a0674db1e0fe8a03d32fccb8c646605 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 25 Nov 2025 16:26:07 +0100 Subject: [PATCH 1/2] fix: link offscreen items and last effect in each block correctly It's possible that due to how new elements are inserted into the array that `effect.last` is wrong. We need to ensure it is really the last item to keep items properly connected to the graph. In addition we link offscreen items after all onscreen items, to ensure they don't have wrong pointers. Fixes #17201 --- .changeset/great-ghosts-unite.md | 5 ++ .../src/internal/client/dom/blocks/each.js | 16 ++++++ .../samples/each-updates-10/_config.js | 51 +++++++++++++++++++ .../samples/each-updates-10/main.svelte | 16 ++++++ 4 files changed, 88 insertions(+) create mode 100644 .changeset/great-ghosts-unite.md create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-10/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-10/main.svelte diff --git a/.changeset/great-ghosts-unite.md b/.changeset/great-ghosts-unite.md new file mode 100644 index 000000000000..2973737cfd11 --- /dev/null +++ b/.changeset/great-ghosts-unite.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: link offscreen items and last effect in each block correctly diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 320c0346902c..faf980d7f3bf 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -503,6 +503,8 @@ function reconcile(state, array, anchor, flags, get_key) { current = item.next; } + let has_offscreen_items = items.size > length; + if (current !== null || seen !== undefined) { var to_destroy = seen === undefined ? [] : array_from(seen); @@ -516,6 +518,8 @@ function reconcile(state, array, anchor, flags, get_key) { var destroy_length = to_destroy.length; + has_offscreen_items = items.size - destroy_length > length; + if (destroy_length > 0) { var controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null; @@ -533,6 +537,18 @@ function reconcile(state, array, anchor, flags, get_key) { } } + // Append offscreen items at the end + if (has_offscreen_items) { + for (const item of items.values()) { + if (!item.o) { + link(state, prev, item); + prev = item; + } + } + } + + state.effect.last = prev && prev.e; + if (is_animated) { queue_micro_task(() => { if (to_animate === undefined) return; diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-10/_config.js b/packages/svelte/tests/runtime-runes/samples/each-updates-10/_config.js new file mode 100644 index 000000000000..d5c9e36f1db6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-10/_config.js @@ -0,0 +1,51 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [add, adjust] = target.querySelectorAll('button'); + + add.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` +

Keyed

+
Item: 1. Index: 0
+
Item: 0. Index: 1
+

Unkeyed

+
Item: 1. Index: 0
+
Item: 0. Index: 1
` + ); + + add.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` +

Keyed

+
Item: 2. Index: 0
+
Item: 1. Index: 1
+
Item: 0. Index: 2
+

Unkeyed

+
Item: 2. Index: 0
+
Item: 1. Index: 1
+
Item: 0. Index: 2
` + ); + + adjust.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` +

Keyed

+
Item: 2. Index: 0
+
Item: 1. Index: 1
+
Item: 10. Index: 2
+

Unkeyed

+
Item: 2. Index: 0
+
Item: 1. Index: 1
+
Item: 10. Index: 2
` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-10/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-updates-10/main.svelte new file mode 100644 index 000000000000..20ce8279de36 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-10/main.svelte @@ -0,0 +1,16 @@ + + + + + +

Keyed

+{#each items as item, index (item)} +
Item: {item.t}. Index: {index}
+{/each} + +

Unkeyed

+{#each items as item, index} +
Item: {item.t}. Index: {index}
+{/each} From 60de03c6e98789af482a80b44bc9dd61b1f46345 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 25 Nov 2025 15:36:47 -0500 Subject: [PATCH 2/2] alternative fix --- .../src/internal/client/dom/blocks/each.js | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index faf980d7f3bf..77e669aa22c1 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -503,8 +503,6 @@ function reconcile(state, array, anchor, flags, get_key) { current = item.next; } - let has_offscreen_items = items.size > length; - if (current !== null || seen !== undefined) { var to_destroy = seen === undefined ? [] : array_from(seen); @@ -518,8 +516,6 @@ function reconcile(state, array, anchor, flags, get_key) { var destroy_length = to_destroy.length; - has_offscreen_items = items.size - destroy_length > length; - if (destroy_length > 0) { var controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null; @@ -537,18 +533,6 @@ function reconcile(state, array, anchor, flags, get_key) { } } - // Append offscreen items at the end - if (has_offscreen_items) { - for (const item of items.values()) { - if (!item.o) { - link(state, prev, item); - prev = item; - } - } - } - - state.effect.last = prev && prev.e; - if (is_animated) { queue_micro_task(() => { if (to_animate === undefined) return; @@ -653,6 +637,10 @@ function link(state, prev, next) { state.first = next; state.effect.first = next && next.e; } else { + if (prev.e === state.effect.last && next !== null) { + state.effect.last = next.e; + } + if (prev.e.next) { prev.e.next.prev = null; } @@ -664,6 +652,10 @@ function link(state, prev, next) { if (next === null) { state.effect.last = prev && prev.e; } else { + if (next.e === state.effect.last && prev === null) { + state.effect.last = next.e.prev; + } + if (next.e.prev) { next.e.prev.next = null; }