From 7c321a478a0674db1e0fe8a03d32fccb8c646605 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 25 Nov 2025 16:26:07 +0100 Subject: [PATCH 1/3] 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 651e90efc99a1354f8ec9060e4a83026faf16a03 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 26 Nov 2025 14:14:30 +0100 Subject: [PATCH 2/3] revert #17244 --- .../svelte/src/internal/client/dom/blocks/each.js | 12 +----------- 1 file changed, 1 insertion(+), 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 23d6a2a7162c..501577053db8 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -657,10 +657,6 @@ 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; } @@ -669,13 +665,7 @@ function link(state, prev, next) { prev.e.next = next && next.e; } - 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 !== null) { if (next.e.prev) { next.e.prev.next = null; } From 30580467d0ebafdd3b1695a549fd8569843763ac Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 26 Nov 2025 14:28:03 +0100 Subject: [PATCH 3/3] add test --- .../samples/each-updates-11/_config.js | 32 +++++++++++++++++++ .../samples/each-updates-11/main.svelte | 11 +++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-11/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-11/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-11/_config.js b/packages/svelte/tests/runtime-runes/samples/each-updates-11/_config.js new file mode 100644 index 000000000000..a8782d2da85b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-11/_config.js @@ -0,0 +1,32 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [add4, add5, modify3] = target.querySelectorAll('button'); + + add4.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` + 1423` + ); + + add5.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` + 14523` + ); + + modify3.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` + 1452updated` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-11/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-updates-11/main.svelte new file mode 100644 index 000000000000..1dcd26509304 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-11/main.svelte @@ -0,0 +1,11 @@ + + + + + + +{#each list as item (item.id)} + {item.text} +{/each}