From 816d877f0c02a2a2f1160925023729c3c5920acc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 26 Nov 2025 21:06:00 -0500 Subject: [PATCH 01/31] fix: destroy each items after siblings are resumed --- .../src/internal/client/dom/blocks/each.js | 102 ++++++++++++++---- .../svelte/src/internal/client/types.d.ts | 7 ++ .../samples/each-updates-12/_config.js | 37 +++++++ .../samples/each-updates-12/main.svelte | 19 ++++ 4 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-12/main.svelte diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 501577053db8..eb19565df281 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -1,4 +1,4 @@ -/** @import { EachItem, EachState, Effect, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */ +/** @import { EachItem, EachOutroGroup, EachState, Effect, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */ /** @import { Batch } from '../../reactivity/batch.js'; */ import { EACH_INDEX_REACTIVE, @@ -75,19 +75,41 @@ function pause_effects(state, to_destroy, controlled_anchor) { var transitions = []; var length = to_destroy.length; + /** @type {EachOutroGroup} */ + var group; + var remaining = to_destroy.length; + for (var i = 0; i < length; i++) { - pause_children(to_destroy[i].e, transitions, true); + pause_effect( + to_destroy[i].e, + () => { + if (group) { + group.remaining -= 1; + + if (group.remaining === 0) { + var groups = /** @type {Set} */ (state.outrogroups); + + destroy_items(state, Array.from(group.items)); + groups.delete(group); + + if (groups.size === 0) { + state.outrogroups = null; + } + } + } else { + remaining -= 1; + } + }, + false + ); } - run_out_transitions(transitions, () => { + if (remaining === 0) { // If we're in a controlled each block (i.e. the block is the only child of an // element), and we are removing all items, _and_ there are no out transitions, // we can use the fast path — emptying the element and replacing the anchor var fast_path = transitions.length === 0 && controlled_anchor !== null; - // TODO only destroy effects if no pending batch needs them. otherwise, - // just set `item.o` back to `false` - if (fast_path) { var anchor = /** @type {Element} */ (controlled_anchor); var parent_node = /** @type {Element} */ (anchor.parentNode); @@ -97,23 +119,43 @@ function pause_effects(state, to_destroy, controlled_anchor) { state.items.clear(); link(state, to_destroy[0].prev, to_destroy[length - 1].next); - } - - for (var i = 0; i < length; i++) { - var item = to_destroy[i]; - if (!fast_path) { - state.items.delete(item.k); - link(state, item.prev, item.next); + for (i = 0; i < length; i++) { + destroy_effect(to_destroy[i].e); } - destroy_effect(item.e, !fast_path); + return; } - if (state.first === to_destroy[0]) { - state.first = to_destroy[0].prev; - } - }); + destroy_items(state, to_destroy); + + // if (state.first === to_destroy[0]) { + // state.first = to_destroy[0].prev; + // } + } else { + group = { + remaining, + items: new Set(to_destroy) + }; + + (state.outrogroups ??= new Set()).add(group); + } +} + +/** + * Pause multiple effects simultaneously, and coordinate their + * subsequent destruction. Used in each blocks + * @param {EachState} state + * @param {EachItem[]} to_destroy + */ +function destroy_items(state, to_destroy) { + for (var i = 0; i < to_destroy.length; i++) { + var item = to_destroy[i]; + + state.items.delete(item.k); + link(state, item.prev, item.next); + destroy_effect(item.e); + } } /** @@ -335,7 +377,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f }); /** @type {EachState} */ - var state = { effect, flags, items, first }; + var state = { effect, flags, items, first, outrogroups: null }; first_run = false; @@ -409,6 +451,15 @@ function reconcile(state, array, anchor, flags, get_key) { item = /** @type {EachItem} */ (items.get(key)); + if (state.outrogroups !== null) { + for (const group of state.outrogroups) { + if (group.items.has(item)) { + group.remaining -= 1; + group.items.delete(item); + } + } + } + state.first ??= item; if (!item.o) { @@ -509,6 +560,19 @@ function reconcile(state, array, anchor, flags, get_key) { let has_offscreen_items = items.size > length; + if (state.outrogroups !== null) { + for (const group of state.outrogroups) { + if (group.remaining === 0) { + destroy_items(state, Array.from(group.items)); + state.outrogroups?.delete(group); + } + } + + if (state.outrogroups.size === 0) { + state.outrogroups = null; + } + } + if (current !== null || seen !== undefined) { var to_destroy = seen === undefined ? [] : array_from(seen); diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 5c682ed14067..458e98b21b8a 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -72,6 +72,11 @@ export type TemplateNode = Text | Element | Comment; export type Dom = TemplateNode | TemplateNode[]; +export type EachOutroGroup = { + remaining: number; + items: Set; +}; + export type EachState = { /** the each block effect */ effect: Effect; @@ -81,6 +86,8 @@ export type EachState = { items: Map; /** head of the linked list of items */ first: EachItem | null; + /** all outro groups that this item is a part of */ + outrogroups: Set | null; }; export type EachItem = { diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js b/packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js new file mode 100644 index 000000000000..e4a9b4f07fda --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js @@ -0,0 +1,37 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, raf }) { + const [clear, push] = target.querySelectorAll('button'); + + raf.tick(0); + + flushSync(() => clear.click()); + raf.tick(1); + + flushSync(() => push.click()); + raf.tick(500); + + assert.htmlEqual( + target.innerHTML, + ` + + + 1 + 2 + ` + ); + + raf.tick(1000); + + assert.htmlEqual( + target.innerHTML, + ` + + + 1 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-12/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-updates-12/main.svelte new file mode 100644 index 000000000000..a65ebd37a82b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-12/main.svelte @@ -0,0 +1,19 @@ + + + + + +{#each items as item} + {item} +{/each} From 5bd9b435c06b4fb3f9e93e79e50ed0994d860562 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 26 Nov 2025 21:06:22 -0500 Subject: [PATCH 02/31] changeset --- .changeset/great-bikes-listen.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/great-bikes-listen.md diff --git a/.changeset/great-bikes-listen.md b/.changeset/great-bikes-listen.md new file mode 100644 index 000000000000..416e0fcceb08 --- /dev/null +++ b/.changeset/great-bikes-listen.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: destroy each items after siblings are resumed From 7feef72d283bad0f41b77667c9f6ffebc137f2f0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 26 Nov 2025 21:16:35 -0500 Subject: [PATCH 03/31] remove unused exports --- .../svelte/src/internal/client/dom/blocks/each.js | 2 -- .../svelte/src/internal/client/reactivity/effects.js | 12 +++--------- 2 files changed, 3 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 eb19565df281..d542ebdd3a95 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -29,8 +29,6 @@ import { block, branch, destroy_effect, - run_out_transitions, - pause_children, pause_effect, resume_effect } from '../../reactivity/effects.js'; diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 4359378e01c8..5ec81c1db21f 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -593,17 +593,11 @@ export function pause_effect(effect, callback, destroy = true) { pause_children(effect, transitions, true); - run_out_transitions(transitions, () => { + var fn = () => { if (destroy) destroy_effect(effect); if (callback) callback(); - }); -} + }; -/** - * @param {TransitionManager[]} transitions - * @param {() => void} fn - */ -export function run_out_transitions(transitions, fn) { var remaining = transitions.length; if (remaining > 0) { var check = () => --remaining || fn(); @@ -620,7 +614,7 @@ export function run_out_transitions(transitions, fn) { * @param {TransitionManager[]} transitions * @param {boolean} local */ -export function pause_children(effect, transitions, local) { +function pause_children(effect, transitions, local) { if ((effect.f & INERT) !== 0) return; effect.f ^= INERT; From f9c2b9e6f431576dc958e12b26ce48e21936ea8b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 26 Nov 2025 21:18:50 -0500 Subject: [PATCH 04/31] tidy up --- packages/svelte/src/internal/client/dom/blocks/each.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index d542ebdd3a95..2032b3b964b7 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -126,10 +126,6 @@ function pause_effects(state, to_destroy, controlled_anchor) { } destroy_items(state, to_destroy); - - // if (state.first === to_destroy[0]) { - // state.first = to_destroy[0].prev; - // } } else { group = { remaining, @@ -147,6 +143,8 @@ function pause_effects(state, to_destroy, controlled_anchor) { * @param {EachItem[]} to_destroy */ function destroy_items(state, to_destroy) { + // TODO only destroy effects if no pending batch needs them. otherwise, + // just set `item.o` back to `false` for (var i = 0; i < to_destroy.length; i++) { var item = to_destroy[i]; From e976d40f927146b4177975ecf99a025a29717d4f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 26 Nov 2025 21:48:34 -0500 Subject: [PATCH 05/31] fix: correctly reconcile each blocks after outroing branches are resumed --- .changeset/flat-cars-say.md | 5 ++++ .../src/internal/client/dom/blocks/each.js | 16 ++++++++----- .../samples/each-updates-12/_config.js | 4 ---- .../samples/each-updates-13/_config.js | 23 +++++++++++++++++++ .../samples/each-updates-13/main.svelte | 19 +++++++++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 .changeset/flat-cars-say.md create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-13/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/each-updates-13/main.svelte diff --git a/.changeset/flat-cars-say.md b/.changeset/flat-cars-say.md new file mode 100644 index 000000000000..1caa75a1106d --- /dev/null +++ b/.changeset/flat-cars-say.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: correctly reconcile each blocks after outroing branches are resumed diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 2032b3b964b7..7ae41e9d37cb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -533,11 +533,7 @@ function reconcile(state, array, anchor, flags, get_key) { stashed = []; while (current !== null && current.k !== key) { - // If the each block isn't inert and an item has an effect that is already inert, - // skip over adding it to our seen Set as the item is already being handled - if ((current.e.f & INERT) === 0) { - (seen ??= new Set()).add(current); - } + (seen ??= new Set()).add(current); stashed.push(current); current = current.next; } @@ -570,7 +566,15 @@ function reconcile(state, array, anchor, flags, get_key) { } if (current !== null || seen !== undefined) { - var to_destroy = seen === undefined ? [] : array_from(seen); + var to_destroy = []; + + if (seen !== undefined) { + for (item of seen) { + if ((item.e.f & INERT) === 0) { + to_destroy.push(item); + } + } + } while (current !== null) { // If the each block isn't inert, then inert effects are currently outroing and will be removed once the transition is finished diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js b/packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js index e4a9b4f07fda..1fee8ceb6790 100644 --- a/packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-12/_config.js @@ -5,11 +5,7 @@ export default test({ async test({ assert, target, raf }) { const [clear, push] = target.querySelectorAll('button'); - raf.tick(0); - flushSync(() => clear.click()); - raf.tick(1); - flushSync(() => push.click()); raf.tick(500); diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-13/_config.js b/packages/svelte/tests/runtime-runes/samples/each-updates-13/_config.js new file mode 100644 index 000000000000..fdf02e486cbe --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-13/_config.js @@ -0,0 +1,23 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, raf }) { + const [clear, reverse] = target.querySelectorAll('button'); + + flushSync(() => clear.click()); + flushSync(() => reverse.click()); + raf.tick(1); + + assert.htmlEqual( + target.innerHTML, + ` + + + c + b + a + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-updates-13/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-updates-13/main.svelte new file mode 100644 index 000000000000..3de3382419ea --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-updates-13/main.svelte @@ -0,0 +1,19 @@ + + + + + +{#each items as item (item)} + {item} +{/each} From bc0383d301797ca4c2a1cc55aeaa2db75aff17b0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 1 Dec 2025 15:02:29 -0500 Subject: [PATCH 06/31] WIP --- packages/svelte/src/internal/client/dom/blocks/each.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 7ae41e9d37cb..4b18c6e97ee6 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -116,7 +116,7 @@ function pause_effects(state, to_destroy, controlled_anchor) { parent_node.append(anchor); state.items.clear(); - link(state, to_destroy[0].prev, to_destroy[length - 1].next); + state.first = null; for (i = 0; i < length; i++) { destroy_effect(to_destroy[i].e); From 6c1de0069a076a1709aec52e09128fbaad83c9eb Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 09:02:20 -0500 Subject: [PATCH 07/31] add some logging --- .../src/internal/client/dom/blocks/each.js | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 4b18c6e97ee6..5be2ac9651b8 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -152,6 +152,8 @@ function destroy_items(state, to_destroy) { link(state, item.prev, item.next); destroy_effect(item.e); } + + log_state(state, 'after destroy_items'); } /** @@ -625,6 +627,56 @@ function reconcile(state, array, anchor, flags, get_key) { } }); } + + log_state(state, 'after reconcile'); +} + +/** + * @param {EachState} state + * @param {string} [message] + */ +function log_state(state, message = 'log_state') { + console.group(message); + + let item = state.first; + let effect = state.effect.first; + + if (item) { + let items = [item]; + while ((item = item.next)) { + if (items.includes(item)) { + throw new Error('items loop'); + } + + items.push(item); + } + console.log(items.map((item) => item.k)); + } else { + console.log('no items'); + } + + if (effect) { + let effects = [effect]; + while ((effect = effect.next)) { + if (effects.includes(effect)) { + throw new Error('items loop'); + } + + effects.push(effect); + } + console.log( + effects.map((effect) => { + let text = effect.nodes_start?.textContent ?? '???'; + if (effect === state.effect.first) text += ' (FIRST)'; + if (effect === state.effect.last) text += ' (LAST)'; + return text; + }) + ); + } else { + console.log('no effects'); + } + + console.groupEnd(); } /** From e89bd0c2df0f346f81c55722304ab2a43dbbaeac Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 17:16:37 -0500 Subject: [PATCH 08/31] fix --- packages/svelte/src/internal/client/dom/blocks/each.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 0478c3ef0dae..ab6aed292bad 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -652,7 +652,7 @@ function log_state(state, message = 'log_state') { } console.log( effects.map((effect) => { - let text = effect.nodes_start?.textContent ?? '???'; + let text = effect.nodes?.start?.textContent ?? '???'; if (effect === state.effect.first) text += ' (FIRST)'; if (effect === state.effect.last) text += ' (LAST)'; return text; From 31bc8d080b7f4913c87760f9554d347aa21d4582 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 18:03:03 -0500 Subject: [PATCH 09/31] remove item on destroy --- .../svelte/src/internal/client/dom/blocks/each.js | 13 ++++++++++--- 1 file changed, 10 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 ab6aed292bad..6baebd2b643c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -136,7 +136,6 @@ function destroy_items(state, to_destroy) { for (var i = 0; i < to_destroy.length; i++) { var item = to_destroy[i]; - state.items.delete(item.k); link(state, item.prev, item.next); destroy_effect(item.e); } @@ -278,6 +277,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } else { item = create_item( + items, first_run ? anchor : null, prev, value, @@ -667,6 +667,7 @@ function log_state(state, message = 'log_state') { /** * @template V + * @param {Map} items * @param {Node | null} anchor * @param {EachItem | null} prev * @param {V} value @@ -677,7 +678,7 @@ function log_state(state, message = 'log_state') { * @param {() => V[]} get_collection * @returns {EachItem} */ -function create_item(anchor, prev, value, key, index, render_fn, flags, get_collection) { +function create_item(items, anchor, prev, value, key, index, render_fn, flags, get_collection) { var reactive = (flags & EACH_ITEM_REACTIVE) !== 0; var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0; @@ -711,7 +712,13 @@ function create_item(anchor, prev, value, key, index, render_fn, flags, get_coll fragment.append((anchor = create_text())); } - item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection)); + item.e = branch(() => { + render_fn(/** @type {Node} */ (anchor), v, i, get_collection); + + return () => { + items.delete(key); + }; + }); if (prev !== null) { // we only need to set `prev.next = item`, because From a3d02c2ce95160b3643172deacf2870a354b77e1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 18:03:55 -0500 Subject: [PATCH 10/31] remove --- .../svelte/src/internal/client/dom/blocks/each.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 6baebd2b643c..61ea5caf6db9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -624,23 +624,8 @@ function reconcile(state, array, anchor, flags, get_key) { function log_state(state, message = 'log_state') { console.group(message); - let item = state.first; let effect = state.effect.first; - if (item) { - let items = [item]; - while ((item = item.next)) { - if (items.includes(item)) { - throw new Error('items loop'); - } - - items.push(item); - } - console.log(items.map((item) => item.k)); - } else { - console.log('no items'); - } - if (effect) { let effects = [effect]; while ((effect = effect.next)) { From 9bdf8e21939199a427bfe8b47da37defe84ed7f9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 18:04:16 -0500 Subject: [PATCH 11/31] remove --- packages/svelte/src/internal/client/dom/blocks/each.js | 1 - packages/svelte/src/internal/client/types.d.ts | 2 -- 2 files changed, 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 61ea5caf6db9..de0beac82436 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -684,7 +684,6 @@ function create_item(items, anchor, prev, value, key, index, render_fn, flags, g var item = { i, v, - k: key, // @ts-expect-error e: null, o: false, diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 7a7c6f43cb56..3ef7268a5a1a 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -97,8 +97,6 @@ export type EachItem = { v: any | Source; /** index */ i: number | Source; - /** key */ - k: unknown; /** true if onscreen */ o: boolean; prev: EachItem | null; From 7044b8181a1d8ffddb92405e479fc4c9c999b7e7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 18:31:25 -0500 Subject: [PATCH 12/31] WIP --- 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 de0beac82436..cc412f2f1177 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -411,6 +411,9 @@ function reconcile(state, array, anchor, flags, get_key) { /** @type {EachItem | undefined} */ var item; + /** @type {Effect | undefined} */ + var effect; + /** @type {number} */ var i; @@ -434,6 +437,7 @@ function reconcile(state, array, anchor, flags, get_key) { key = get_key(value, i); item = /** @type {EachItem} */ (items.get(key)); + effect = item.e; if (state.outrogroups !== null) { for (const group of state.outrogroups) { @@ -464,10 +468,10 @@ function reconcile(state, array, anchor, flags, get_key) { continue; } - if ((item.e.f & INERT) !== 0) { - resume_effect(item.e); + if ((effect.f & INERT) !== 0) { + resume_effect(effect); if (is_animated) { - item.e.nodes?.a?.unfix(); + effect.nodes?.a?.unfix(); (to_animate ??= new Set()).delete(item); } } From 73030d53904d3a2e90a7864c6bf2e08fdbcaf502 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 18:34:34 -0500 Subject: [PATCH 13/31] WIP --- packages/svelte/src/internal/client/dom/blocks/each.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index cc412f2f1177..d43593471c42 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -533,8 +533,6 @@ function reconcile(state, array, anchor, flags, get_key) { if (current === null) { continue; } - - item = current; } matched.push(item); From 3293853a9a90556180323771772e55568d99c7e0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 19:47:00 -0500 Subject: [PATCH 14/31] WIP --- .../src/internal/client/dom/blocks/each.js | 196 ++++++++---------- .../svelte/src/internal/client/types.d.ts | 6 +- 2 files changed, 84 insertions(+), 118 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index d43593471c42..d86378079b02 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -53,7 +53,7 @@ export function index(_, i) { * Pause multiple effects simultaneously, and coordinate their * subsequent destruction. Used in each blocks * @param {EachState} state - * @param {EachItem[]} to_destroy + * @param {Effect[]} to_destroy * @param {null | Node} controlled_anchor */ function pause_effects(state, to_destroy, controlled_anchor) { @@ -67,7 +67,7 @@ function pause_effects(state, to_destroy, controlled_anchor) { for (var i = 0; i < length; i++) { pause_effect( - to_destroy[i].e, + to_destroy[i], () => { if (group) { group.remaining -= 1; @@ -75,7 +75,7 @@ function pause_effects(state, to_destroy, controlled_anchor) { if (group.remaining === 0) { var groups = /** @type {Set} */ (state.outrogroups); - destroy_items(state, Array.from(group.items)); + destroy_items(state, Array.from(group.effects)); groups.delete(group); if (groups.size === 0) { @@ -104,10 +104,9 @@ function pause_effects(state, to_destroy, controlled_anchor) { parent_node.append(anchor); state.items.clear(); - state.first = null; for (i = 0; i < length; i++) { - destroy_effect(to_destroy[i].e); + destroy_effect(to_destroy[i]); } return; @@ -117,7 +116,7 @@ function pause_effects(state, to_destroy, controlled_anchor) { } else { group = { remaining, - items: new Set(to_destroy) + effects: new Set(to_destroy) }; (state.outrogroups ??= new Set()).add(group); @@ -128,16 +127,16 @@ function pause_effects(state, to_destroy, controlled_anchor) { * Pause multiple effects simultaneously, and coordinate their * subsequent destruction. Used in each blocks * @param {EachState} state - * @param {EachItem[]} to_destroy + * @param {Effect[]} to_destroy */ function destroy_items(state, to_destroy) { // TODO only destroy effects if no pending batch needs them. otherwise, // just set `item.o` back to `false` for (var i = 0; i < to_destroy.length; i++) { - var item = to_destroy[i]; + var effect = to_destroy[i]; - link(state, item.prev, item.next); - destroy_effect(item.e); + link(state, effect.prev, effect.next); + destroy_effect(effect); } log_state(state, 'after destroy_items'); @@ -159,9 +158,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f /** @type {Map} */ var items = new Map(); - /** @type {EachItem | null} */ - var first = null; - var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; var is_reactive_value = (flags & EACH_ITEM_REACTIVE) !== 0; var is_reactive_index = (flags & EACH_INDEX_REACTIVE) !== 0; @@ -241,7 +237,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var keys = new Set(); var batch = /** @type {Batch} */ (current_batch); - var prev = null; var defer = should_defer_append(); for (var i = 0; i < length; i += 1) { @@ -279,7 +274,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f item = create_item( items, first_run ? anchor : null, - prev, value, key, i, @@ -290,14 +284,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f if (first_run) { item.o = true; - - if (prev === null) { - first = item; - } else { - prev.next = item; - } - - prev = item; } items.set(key, item); @@ -361,7 +347,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f }); /** @type {EachState} */ - var state = { effect, flags, items, first, outrogroups: null }; + var state = { effect, flags, items, outrogroups: null }; first_run = false; @@ -385,21 +371,21 @@ function reconcile(state, array, anchor, flags, get_key) { var length = array.length; var items = state.items; - var current = state.first; + var current = state.effect.first; - /** @type {undefined | Set} */ + /** @type {undefined | Set} */ var seen; - /** @type {EachItem | null} */ + /** @type {Effect | null} */ var prev = null; - /** @type {undefined | Set} */ + /** @type {undefined | Set} */ var to_animate; - /** @type {EachItem[]} */ + /** @type {Effect[]} */ var matched = []; - /** @type {EachItem[]} */ + /** @type {Effect[]} */ var stashed = []; /** @type {V} */ @@ -427,7 +413,7 @@ function reconcile(state, array, anchor, flags, get_key) { // else this would happen https://github.com/sveltejs/svelte/issues/17181 if (item.o) { item.e.nodes?.a?.measure(); - (to_animate ??= new Set()).add(item); + (to_animate ??= new Set()).add(item.e); } } } @@ -441,43 +427,45 @@ function reconcile(state, array, anchor, flags, get_key) { if (state.outrogroups !== null) { for (const group of state.outrogroups) { - if (group.items.has(item)) { + if (group.effects.has(effect)) { group.remaining -= 1; - group.items.delete(item); + group.effects.delete(effect); } } } - state.first ??= item; - if (!item.o) { item.o = true; - var next = prev ? prev.next : current; + if (effect === current) { + move(effect, null, anchor); + } else { + var next = prev ? prev.next : current; - link(state, prev, item); - link(state, item, next); + link(state, prev, effect); + link(state, effect, next); - move(item, next, anchor); - prev = item; + move(effect, next, anchor); + prev = effect; - matched = []; - stashed = []; + matched = []; + stashed = []; - current = prev.next; - continue; + current = prev.next; + continue; + } } if ((effect.f & INERT) !== 0) { resume_effect(effect); if (is_animated) { effect.nodes?.a?.unfix(); - (to_animate ??= new Set()).delete(item); + (to_animate ??= new Set()).delete(effect); } } - if (item !== current) { - if (seen !== undefined && seen.has(item)) { + if (effect !== current) { + if (seen !== undefined && seen.has(effect)) { if (matched.length < stashed.length) { // more efficient to move later items to the front var start = stashed[0]; @@ -508,14 +496,14 @@ function reconcile(state, array, anchor, flags, get_key) { stashed = []; } else { // more efficient to move earlier items to the back - seen.delete(item); - move(item, current, anchor); + seen.delete(effect); + move(effect, current, anchor); - link(state, item.prev, item.next); - link(state, item, prev === null ? state.first : prev.next); - link(state, prev, item); + link(state, effect.prev, effect.next); + link(state, effect, prev === null ? state.effect.first : prev.next); + link(state, prev, effect); - prev = item; + prev = effect; } continue; @@ -524,7 +512,7 @@ function reconcile(state, array, anchor, flags, get_key) { matched = []; stashed = []; - while (current !== null && current !== item) { + while (current !== null && current !== effect) { (seen ??= new Set()).add(current); stashed.push(current); current = current.next; @@ -535,9 +523,9 @@ function reconcile(state, array, anchor, flags, get_key) { } } - matched.push(item); - prev = item; - current = item.next; + matched.push(effect); + prev = effect; + current = effect.next; } let has_offscreen_items = items.size > length; @@ -545,7 +533,7 @@ function reconcile(state, array, anchor, flags, get_key) { if (state.outrogroups !== null) { for (const group of state.outrogroups) { if (group.remaining === 0) { - destroy_items(state, Array.from(group.items)); + destroy_items(state, Array.from(group.effects)); state.outrogroups?.delete(group); } } @@ -556,19 +544,20 @@ function reconcile(state, array, anchor, flags, get_key) { } if (current !== null || seen !== undefined) { + /** @type {Effect[]} */ var to_destroy = []; if (seen !== undefined) { - for (item of seen) { - if ((item.e.f & INERT) === 0) { - to_destroy.push(item); + for (effect of seen) { + if ((effect.f & INERT) === 0) { + to_destroy.push(effect); } } } while (current !== null) { // If the each block isn't inert, then inert effects are currently outroing and will be removed once the transition is finished - if ((current.e.f & INERT) === 0) { + if ((current.f & INERT) === 0) { to_destroy.push(current); } current = current.next; @@ -583,11 +572,11 @@ function reconcile(state, array, anchor, flags, get_key) { if (is_animated) { for (i = 0; i < destroy_length; i += 1) { - to_destroy[i].e.nodes?.a?.measure(); + to_destroy[i].nodes?.a?.measure(); } for (i = 0; i < destroy_length; i += 1) { - to_destroy[i].e.nodes?.a?.fix(); + to_destroy[i].nodes?.a?.fix(); } } @@ -599,19 +588,19 @@ function reconcile(state, array, anchor, flags, get_key) { if (has_offscreen_items) { for (const item of items.values()) { if (!item.o) { - link(state, prev, item); - prev = item; + link(state, prev, item.e); + prev = item.e; } } } - state.effect.last = prev && prev.e; + state.effect.last = prev; if (is_animated) { queue_micro_task(() => { if (to_animate === undefined) return; - for (item of to_animate) { - item.e.nodes?.a?.apply(); + for (effect of to_animate) { + effect.nodes?.a?.apply(); } }); } @@ -656,7 +645,6 @@ function log_state(state, message = 'log_state') { * @template V * @param {Map} items * @param {Node | null} anchor - * @param {EachItem | null} prev * @param {V} value * @param {unknown} key * @param {number} index @@ -665,7 +653,7 @@ function log_state(state, message = 'log_state') { * @param {() => V[]} get_collection * @returns {EachItem} */ -function create_item(items, anchor, prev, value, key, index, render_fn, flags, get_collection) { +function create_item(items, anchor, value, key, index, render_fn, flags, get_collection) { var reactive = (flags & EACH_ITEM_REACTIVE) !== 0; var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0; @@ -682,52 +670,37 @@ function create_item(items, anchor, prev, value, key, index, render_fn, flags, g }; } - /** @type {EachItem} */ - var item = { - i, - v, - // @ts-expect-error - e: null, - o: false, - prev, - next: null - }; - if (anchor === null) { var fragment = document.createDocumentFragment(); fragment.append((anchor = create_text())); } - item.e = branch(() => { - render_fn(/** @type {Node} */ (anchor), v, i, get_collection); - - return () => { - items.delete(key); - }; - }); - - if (prev !== null) { - // we only need to set `prev.next = item`, because - // `item.prev = prev` was set on initialization. - // the effects themselves are already linked - prev.next = item; - } + return { + i, + v, + o: false, + e: branch(() => { + render_fn(/** @type {Node} */ (anchor), v, i, get_collection); - return item; + return () => { + items.delete(key); + }; + }) + }; } /** - * @param {EachItem} item - * @param {EachItem | null} next + * @param {Effect} item + * @param {Effect | null} next * @param {Text | Element | Comment} anchor */ function move(item, next, anchor) { - if (!item.e.nodes) return; + if (!item.nodes) return; - var end = item.next ? /** @type {EffectNodes} */ (item.next.e.nodes).start : anchor; + var end = item.next ? /** @type {EffectNodes} */ (item.next.nodes).start : anchor; - var dest = next ? /** @type {EffectNodes} */ (next.e.nodes).start : anchor; - var node = /** @type {TemplateNode} */ (item.e.nodes.start); + var dest = next ? /** @type {EffectNodes} */ (next.nodes).start : anchor; + var node = /** @type {TemplateNode} */ (item.nodes.start); while (node !== null && node !== end) { var next_node = /** @type {TemplateNode} */ (get_next_sibling(node)); @@ -738,28 +711,25 @@ function move(item, next, anchor) { /** * @param {EachState} state - * @param {EachItem | null} prev - * @param {EachItem | null} next + * @param {Effect | null} prev + * @param {Effect | null} next */ function link(state, prev, next) { if (prev === null) { - state.first = next; - state.effect.first = next && next.e; + state.effect.first = next; } else { - if (prev.e.next) { - prev.e.next.prev = null; + if (prev.next) { + prev.next.prev = null; } prev.next = next; - prev.e.next = next && next.e; } if (next !== null) { - if (next.e.prev) { - next.e.prev.next = null; + if (next.prev) { + next.prev.next = null; } next.prev = prev; - next.e.prev = prev && prev.e; } } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 3ef7268a5a1a..475dd120f753 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -74,7 +74,7 @@ export type Dom = TemplateNode | TemplateNode[]; export type EachOutroGroup = { remaining: number; - items: Set; + effects: Set; }; export type EachState = { @@ -84,8 +84,6 @@ export type EachState = { flags: number; /** a key -> item lookup */ items: Map; - /** head of the linked list of items */ - first: EachItem | null; /** all outro groups that this item is a part of */ outrogroups: Set | null; }; @@ -99,8 +97,6 @@ export type EachItem = { i: number | Source; /** true if onscreen */ o: boolean; - prev: EachItem | null; - next: EachItem | null; }; export interface TransitionManager { From be0785e6a2c6a51b23d1b469505875aba730cea4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 20:05:26 -0500 Subject: [PATCH 15/31] WIP --- .../svelte/src/internal/client/constants.js | 1 + .../src/internal/client/dom/blocks/each.js | 39 +++++++++---------- .../svelte/src/internal/client/types.d.ts | 8 ++-- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index a121e6674aac..a1bdb8a98503 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -40,6 +40,7 @@ export const EAGER_EFFECT = 1 << 17; export const HEAD_EFFECT = 1 << 18; export const EFFECT_PRESERVED = 1 << 19; export const USER_EFFECT = 1 << 20; +export const EFFECT_OFFSCREEN = 1 << 25; // Flags exclusive to deriveds /** diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index d86378079b02..50ac3836a3c7 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -34,7 +34,7 @@ import { } from '../../reactivity/effects.js'; import { source, mutable_source, internal_set } from '../../reactivity/sources.js'; import { array_from, is_array } from '../../../shared/utils.js'; -import { COMMENT_NODE, INERT } from '#client/constants'; +import { COMMENT_NODE, EFFECT_OFFSCREEN, INERT } from '#client/constants'; import { queue_micro_task } from '../task.js'; import { get } from '../../runtime.js'; import { DEV } from 'esm-env'; @@ -131,7 +131,7 @@ function pause_effects(state, to_destroy, controlled_anchor) { */ function destroy_items(state, to_destroy) { // TODO only destroy effects if no pending batch needs them. otherwise, - // just set `item.o` back to `false` + // just re-add the `EFFECT_OFFSCREEN` flag for (var i = 0; i < to_destroy.length; i++) { var effect = to_destroy[i]; @@ -282,8 +282,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f get_collection ); - if (first_run) { - item.o = true; + if (!first_run) { + item.e.f |= EFFECT_OFFSCREEN; } items.set(key, item); @@ -394,9 +394,6 @@ function reconcile(state, array, anchor, flags, get_key) { /** @type {any} */ var key; - /** @type {EachItem | undefined} */ - var item; - /** @type {Effect | undefined} */ var effect; @@ -407,13 +404,13 @@ function reconcile(state, array, anchor, flags, get_key) { for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); - item = /** @type {EachItem} */ (items.get(key)); + effect = /** @type {EachItem} */ (items.get(key)).e; // offscreen == coming in now, no animation in that case, // else this would happen https://github.com/sveltejs/svelte/issues/17181 - if (item.o) { - item.e.nodes?.a?.measure(); - (to_animate ??= new Set()).add(item.e); + if ((effect.f & EFFECT_OFFSCREEN) === 0) { + effect.nodes?.a?.measure(); + (to_animate ??= new Set()).add(effect); } } } @@ -422,8 +419,7 @@ function reconcile(state, array, anchor, flags, get_key) { value = array[i]; key = get_key(value, i); - item = /** @type {EachItem} */ (items.get(key)); - effect = item.e; + effect = /** @type {EachItem} */ (items.get(key)).e; if (state.outrogroups !== null) { for (const group of state.outrogroups) { @@ -434,8 +430,8 @@ function reconcile(state, array, anchor, flags, get_key) { } } - if (!item.o) { - item.o = true; + if ((effect.f & EFFECT_OFFSCREEN) !== 0) { + effect.f ^= EFFECT_OFFSCREEN; if (effect === current) { move(effect, null, anchor); @@ -586,10 +582,12 @@ 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.e); - prev = item.e; + for (var item of items.values()) { + effect = item.e; + + if ((effect.f & EFFECT_OFFSCREEN) !== 0) { + link(state, prev, effect); + prev = effect; } } } @@ -676,9 +674,8 @@ function create_item(items, anchor, value, key, index, render_fn, flags, get_col } return { - i, v, - o: false, + i, e: branch(() => { render_fn(/** @type {Node} */ (anchor), v, i, get_collection); diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 475dd120f753..2b0de5a0396b 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -89,14 +89,12 @@ export type EachState = { }; export type EachItem = { - /** effect */ - e: Effect; - /** item */ + /** value */ v: any | Source; /** index */ i: number | Source; - /** true if onscreen */ - o: boolean; + /** effect */ + e: Effect; }; export interface TransitionManager { From 25b44d58722257457995958b1308224b63c14af5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 21:33:34 -0500 Subject: [PATCH 16/31] WIP --- .../src/internal/client/dom/blocks/each.js | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 50ac3836a3c7..d2161c2ce465 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -133,10 +133,7 @@ function destroy_items(state, to_destroy) { // TODO only destroy effects if no pending batch needs them. otherwise, // just re-add the `EFFECT_OFFSCREEN` flag for (var i = 0; i < to_destroy.length; i++) { - var effect = to_destroy[i]; - - link(state, effect.prev, effect.next); - destroy_effect(effect); + destroy_effect(to_destroy[i]); } log_state(state, 'after destroy_items'); @@ -580,20 +577,6 @@ function reconcile(state, array, anchor, flags, get_key) { } } - // Append offscreen items at the end - if (has_offscreen_items) { - for (var item of items.values()) { - effect = item.e; - - if ((effect.f & EFFECT_OFFSCREEN) !== 0) { - link(state, prev, effect); - prev = effect; - } - } - } - - state.effect.last = prev; - if (is_animated) { queue_micro_task(() => { if (to_animate === undefined) return; @@ -624,14 +607,17 @@ function log_state(state, message = 'log_state') { effects.push(effect); } - console.log( - effects.map((effect) => { - let text = effect.nodes?.start?.textContent ?? '???'; - if (effect === state.effect.first) text += ' (FIRST)'; - if (effect === state.effect.last) text += ' (LAST)'; - return text; - }) - ); + + for (let i = 0; i < effects.length; i += 1) { + let effect = effects[i]; + + let text = effect.nodes?.start?.textContent ?? '???'; + + if (effect === state.effect.first) text += ' (FIRST)'; + if (effect === state.effect.last) text += ' (LAST)'; + + console.log(`%c${text}`, `color: ${(effect.f & INERT) !== 0 ? 'grey' : 'black'}`); + } } else { console.log('no effects'); } From 5d724f10e1d2458ffbe11f2034a8683a4a7393f1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 21:49:00 -0500 Subject: [PATCH 17/31] WIP --- .../src/internal/client/dom/blocks/each.js | 50 ++++++++----------- .../svelte/src/internal/client/types.d.ts | 4 +- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index d2161c2ce465..6a9e7e4e46af 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -139,6 +139,8 @@ function destroy_items(state, to_destroy) { log_state(state, 'after destroy_items'); } +var offscreen_anchor = create_text(); + /** * @template V * @param {Element | Comment} node The next sibling node, or the parent node if this is a 'controlled' block @@ -156,8 +158,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var items = new Map(); var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; - var is_reactive_value = (flags & EACH_ITEM_REACTIVE) !== 0; - var is_reactive_index = (flags & EACH_INDEX_REACTIVE) !== 0; if (is_controlled) { var parent_node = /** @type {Element} */ (node); @@ -236,7 +236,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var batch = /** @type {Batch} */ (current_batch); var defer = should_defer_append(); - for (var i = 0; i < length; i += 1) { + for (var index = 0; index < length; index += 1) { if ( hydrating && hydrate_node.nodeType === COMMENT_NODE && @@ -249,20 +249,15 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f set_hydrating(false); } - var value = array[i]; - var key = get_key(value, i); + var value = array[index]; + var key = get_key(value, index); var item = first_run ? null : items.get(key); if (item) { // update before reconciliation, to trigger any async updates - if (is_reactive_value) { - internal_set(item.v, value); - } - - if (is_reactive_index) { - internal_set(/** @type {Value} */ (item.i), i); - } + if (item.v) internal_set(item.v, value); + if (item.i) internal_set(item.i, index); if (defer) { batch.skipped_effects.delete(item.e); @@ -270,10 +265,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } else { item = create_item( items, - first_run ? anchor : null, + first_run ? anchor : offscreen_anchor, value, key, - i, + index, render_fn, flags, get_collection @@ -628,7 +623,7 @@ function log_state(state, message = 'log_state') { /** * @template V * @param {Map} items - * @param {Node | null} anchor + * @param {Node} anchor * @param {V} value * @param {unknown} key * @param {number} index @@ -638,32 +633,29 @@ function log_state(state, message = 'log_state') { * @returns {EachItem} */ function create_item(items, anchor, value, key, index, render_fn, flags, get_collection) { - var reactive = (flags & EACH_ITEM_REACTIVE) !== 0; - var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0; + var v = + (flags & EACH_ITEM_REACTIVE) !== 0 + ? (flags & EACH_ITEM_IMMUTABLE) === 0 + ? mutable_source(value, false, false) + : source(value) + : null; - var v = reactive ? (mutable ? mutable_source(value, false, false) : source(value)) : value; - var i = (flags & EACH_INDEX_REACTIVE) === 0 ? index : source(index); + var i = (flags & EACH_INDEX_REACTIVE) !== 0 ? source(index) : null; - if (DEV && reactive) { + if (DEV && v) { // For tracing purposes, we need to link the source signal we create with the // collection + index so that tracing works as intended - /** @type {Value} */ (v).trace = () => { - var collection_index = typeof i === 'number' ? index : i.v; + v.trace = () => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions - get_collection()[collection_index]; + get_collection()[i?.v ?? index]; }; } - if (anchor === null) { - var fragment = document.createDocumentFragment(); - fragment.append((anchor = create_text())); - } - return { v, i, e: branch(() => { - render_fn(/** @type {Node} */ (anchor), v, i, get_collection); + render_fn(anchor, v ?? value, i ?? index, get_collection); return () => { items.delete(key); diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 2b0de5a0396b..9cffc70e3c46 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -90,9 +90,9 @@ export type EachState = { export type EachItem = { /** value */ - v: any | Source; + v: Source | null; /** index */ - i: number | Source; + i: Source | null; /** effect */ e: Effect; }; From cd3c946e63d589f67e40aaea0a7d25071cd54f02 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 21:55:00 -0500 Subject: [PATCH 18/31] WIP --- packages/svelte/src/internal/client/dom/blocks/each.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 6a9e7e4e46af..d48adb35d1de 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -516,8 +516,6 @@ function reconcile(state, array, anchor, flags, get_key) { current = effect.next; } - let has_offscreen_items = items.size > length; - if (state.outrogroups !== null) { for (const group of state.outrogroups) { if (group.remaining === 0) { @@ -553,8 +551,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; From 3c0fcdfe4659938d63f14796474f8d0214f55897 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 22:36:37 -0500 Subject: [PATCH 19/31] WIP --- .../src/internal/client/dom/blocks/each.js | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index d48adb35d1de..263a9a492cb3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -392,6 +392,8 @@ function reconcile(state, array, anchor, flags, get_key) { /** @type {number} */ var i; + log_state(state, 'before reconcile'); + if (is_animated) { for (i = 0; i < length; i += 1) { value = array[i]; @@ -430,8 +432,26 @@ function reconcile(state, array, anchor, flags, get_key) { } else { var next = prev ? prev.next : current; + if (effect === state.effect.last) { + state.effect.last = effect.prev; + } + + console.group('insert offscreen effect'); + console.log({ + effect: effect.nodes?.start.textContent, + prev: prev?.nodes?.start.textContent, + next: next?.nodes?.start.textContent, + current: current?.nodes?.start.textContent, + effect_prev: effect.prev?.nodes?.start.textContent, + effect_next: effect.next?.nodes?.start.textContent + }); + if (effect.prev) effect.prev.next = effect.next; + if (effect.next) effect.next.prev = effect.prev; + log_state(state, 'after yoink'); link(state, prev, effect); link(state, effect, next); + log_state(state, 'after link'); + console.groupEnd(); move(effect, next, anchor); prev = effect; @@ -454,6 +474,12 @@ function reconcile(state, array, anchor, flags, get_key) { if (effect !== current) { if (seen !== undefined && seen.has(effect)) { + console.log({ + effect: effect.nodes?.start.textContent, + matched: matched.map((e) => e.nodes?.start.textContent).join(' '), + stashed: stashed.map((e) => e.nodes?.start.textContent).join(' ') + }); + if (matched.length < stashed.length) { // more efficient to move later items to the front var start = stashed[0]; @@ -686,21 +712,17 @@ function move(item, next, anchor) { * @param {Effect | null} next */ function link(state, prev, next) { + console.log('link', prev?.nodes?.start.textContent, next?.nodes?.start.textContent); + if (prev === null) { state.effect.first = next; } else { - if (prev.next) { - prev.next.prev = null; - } - prev.next = next; } - if (next !== null) { - if (next.prev) { - next.prev.next = null; - } - + if (next === null) { + state.effect.last = prev; + } else { next.prev = prev; } } From 5da57ac02530d49fcc359ad9d9c7d21cace639b5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 22:43:27 -0500 Subject: [PATCH 20/31] WIP --- packages/svelte/src/internal/client/dom/blocks/each.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 263a9a492cb3..6def27b0d516 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -633,7 +633,10 @@ function log_state(state, message = 'log_state') { if (effect === state.effect.first) text += ' (FIRST)'; if (effect === state.effect.last) text += ' (LAST)'; - console.log(`%c${text}`, `color: ${(effect.f & INERT) !== 0 ? 'grey' : 'black'}`); + console.log( + `%c${text}`, + `color: ${(effect.f & EFFECT_OFFSCREEN) !== 0 ? 'magenta' : (effect.f & INERT) !== 0 ? 'grey' : 'black'}` + ); } } else { console.log('no effects'); From eace2cd1e02c2abec1bd03e5df320d042e9159a4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 23:00:45 -0500 Subject: [PATCH 21/31] WIP --- .../src/internal/client/dom/blocks/each.js | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 6def27b0d516..f7eef2c3581b 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -415,6 +415,8 @@ function reconcile(state, array, anchor, flags, get_key) { effect = /** @type {EachItem} */ (items.get(key)).e; + console.log({ key }); + if (state.outrogroups !== null) { for (const group of state.outrogroups) { if (group.effects.has(effect)) { @@ -427,31 +429,33 @@ function reconcile(state, array, anchor, flags, get_key) { if ((effect.f & EFFECT_OFFSCREEN) !== 0) { effect.f ^= EFFECT_OFFSCREEN; + console.group('insert offscreen effect'); + console.log({ + effect: effect.nodes?.start.textContent, + prev: prev?.nodes?.start.textContent, + next: next?.nodes?.start.textContent, + current: current?.nodes?.start.textContent, + effect_prev: effect.prev?.nodes?.start.textContent, + effect_next: effect.next?.nodes?.start.textContent + }); + if (effect === current) { + console.warn('>>>> is current', effect.nodes?.start.textContent); move(effect, null, anchor); } else { + console.warn('>>>> is NOT current', effect.nodes?.start.textContent); var next = prev ? prev.next : current; if (effect === state.effect.last) { state.effect.last = effect.prev; } - console.group('insert offscreen effect'); - console.log({ - effect: effect.nodes?.start.textContent, - prev: prev?.nodes?.start.textContent, - next: next?.nodes?.start.textContent, - current: current?.nodes?.start.textContent, - effect_prev: effect.prev?.nodes?.start.textContent, - effect_next: effect.next?.nodes?.start.textContent - }); if (effect.prev) effect.prev.next = effect.next; if (effect.next) effect.next.prev = effect.prev; log_state(state, 'after yoink'); link(state, prev, effect); link(state, effect, next); log_state(state, 'after link'); - console.groupEnd(); move(effect, next, anchor); prev = effect; @@ -462,6 +466,9 @@ function reconcile(state, array, anchor, flags, get_key) { current = prev.next; continue; } + + log_state(state, 'after insert'); + console.groupEnd(); } if ((effect.f & INERT) !== 0) { @@ -513,11 +520,23 @@ function reconcile(state, array, anchor, flags, get_key) { seen.delete(effect); move(effect, current, anchor); + console.group('move earlier items back'); + console.log({ + effect: effect.nodes?.start.textContent, + prev: prev?.nodes?.start.textContent, + current: current?.nodes?.start.textContent, + effect_prev: effect.prev?.nodes?.start.textContent, + effect_next: effect.next?.nodes?.start.textContent + }); + link(state, effect.prev, effect.next); link(state, effect, prev === null ? state.effect.first : prev.next); link(state, prev, effect); prev = effect; + + log_state(state, 'after move'); + console.groupEnd(); } continue; @@ -537,7 +556,10 @@ function reconcile(state, array, anchor, flags, get_key) { } } - matched.push(effect); + if ((effect.f & EFFECT_OFFSCREEN) === 0) { + matched.push(effect); + } + prev = effect; current = effect.next; } @@ -699,7 +721,11 @@ function move(item, next, anchor) { var end = item.next ? /** @type {EffectNodes} */ (item.next.nodes).start : anchor; - var dest = next ? /** @type {EffectNodes} */ (next.nodes).start : anchor; + var dest = + next && (next.f & EFFECT_OFFSCREEN) === 0 + ? /** @type {EffectNodes} */ (next.nodes).start + : anchor; + var node = /** @type {TemplateNode} */ (item.nodes.start); while (node !== null && node !== end) { From 58974c34ac6c124a52f56bc4284eee92af24c666 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 23:28:53 -0500 Subject: [PATCH 22/31] WIP --- .../src/internal/client/dom/blocks/each.js | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index f7eef2c3581b..7f21f88dfbb3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -494,11 +494,37 @@ function reconcile(state, array, anchor, flags, get_key) { prev = start.prev; + console.group('move later items to the front'); + console.log({ + effect: effect.nodes?.start.textContent, + prev: prev?.nodes?.start.textContent, + start: start?.nodes?.start.textContent, + current: current?.nodes?.start.textContent, + effect_prev: effect.prev?.nodes?.start.textContent, + effect_next: effect.next?.nodes?.start.textContent + }); + var a = matched[0]; var b = matched[matched.length - 1]; for (j = 0; j < matched.length; j += 1) { + console.group('moving', matched[j].nodes?.start.textContent); move(matched[j], start, anchor); + + console.log( + 'output:', + Array.from(document.querySelector('#output')?.childNodes) + .map((node) => { + if (node.nodeType === node.TEXT_NODE && /** @type {Text} */ (node).data === '') { + return ''; + } + + return node.textContent; + }) + .join(' ') + ); + + console.groupEnd(); } for (j = 0; j < stashed.length; j += 1) { @@ -509,6 +535,9 @@ function reconcile(state, array, anchor, flags, get_key) { link(state, prev, a); link(state, b, start); + log_state(state, 'after move'); + console.groupEnd(); + current = start; prev = b; i -= 1; @@ -547,6 +576,7 @@ function reconcile(state, array, anchor, flags, get_key) { while (current !== null && current !== effect) { (seen ??= new Set()).add(current); + console.log('stashing', current.nodes?.start.textContent); stashed.push(current); current = current.next; } @@ -664,6 +694,19 @@ function log_state(state, message = 'log_state') { console.log('no effects'); } + console.log( + 'output:', + Array.from(document.querySelector('#output')?.childNodes) + .map((node) => { + if (node.nodeType === node.TEXT_NODE && /** @type {Text} */ (node).data === '') { + return ''; + } + + return node.textContent; + }) + .join(' ') + ); + console.groupEnd(); } @@ -719,7 +762,8 @@ function create_item(items, anchor, value, key, index, render_fn, flags, get_col function move(item, next, anchor) { if (!item.nodes) return; - var end = item.next ? /** @type {EffectNodes} */ (item.next.nodes).start : anchor; + // var end = item.next ? /** @type {EffectNodes} */ (item.next.nodes).start : anchor; + var end = item.nodes.end; var dest = next && (next.f & EFFECT_OFFSCREEN) === 0 @@ -728,9 +772,16 @@ function move(item, next, anchor) { var node = /** @type {TemplateNode} */ (item.nodes.start); - while (node !== null && node !== end) { + console.log({ node, dest, end }); + + while (node !== null) { var next_node = /** @type {TemplateNode} */ (get_next_sibling(node)); dest.before(node); + + if (node === end) { + return; + } + node = next_node; } } From 75356aa0df8276bdd0a1d0533197a4d5b35d4179 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 2 Dec 2025 23:34:27 -0500 Subject: [PATCH 23/31] WIP --- .../src/internal/client/dom/blocks/each.js | 116 +++++++++--------- 1 file changed, 57 insertions(+), 59 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 7f21f88dfbb3..9cebbc9704cb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -139,7 +139,8 @@ function destroy_items(state, to_destroy) { log_state(state, 'after destroy_items'); } -var offscreen_anchor = create_text(); +/** @type {TemplateNode} */ +var offscreen_anchor; /** * @template V @@ -265,7 +266,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } else { item = create_item( items, - first_run ? anchor : offscreen_anchor, + first_run ? anchor : (offscreen_anchor ??= create_text()), value, key, index, @@ -415,7 +416,7 @@ function reconcile(state, array, anchor, flags, get_key) { effect = /** @type {EachItem} */ (items.get(key)).e; - console.log({ key }); + // console.log({ key }); if (state.outrogroups !== null) { for (const group of state.outrogroups) { @@ -429,21 +430,21 @@ function reconcile(state, array, anchor, flags, get_key) { if ((effect.f & EFFECT_OFFSCREEN) !== 0) { effect.f ^= EFFECT_OFFSCREEN; - console.group('insert offscreen effect'); - console.log({ - effect: effect.nodes?.start.textContent, - prev: prev?.nodes?.start.textContent, - next: next?.nodes?.start.textContent, - current: current?.nodes?.start.textContent, - effect_prev: effect.prev?.nodes?.start.textContent, - effect_next: effect.next?.nodes?.start.textContent - }); + // console.group('insert offscreen effect'); + // console.log({ + // effect: effect.nodes?.start.textContent, + // prev: prev?.nodes?.start.textContent, + // next: next?.nodes?.start.textContent, + // current: current?.nodes?.start.textContent, + // effect_prev: effect.prev?.nodes?.start.textContent, + // effect_next: effect.next?.nodes?.start.textContent + // }); if (effect === current) { - console.warn('>>>> is current', effect.nodes?.start.textContent); + // console.warn('>>>> is current', effect.nodes?.start.textContent); move(effect, null, anchor); } else { - console.warn('>>>> is NOT current', effect.nodes?.start.textContent); + // console.warn('>>>> is NOT current', effect.nodes?.start.textContent); var next = prev ? prev.next : current; if (effect === state.effect.last) { @@ -468,7 +469,7 @@ function reconcile(state, array, anchor, flags, get_key) { } log_state(state, 'after insert'); - console.groupEnd(); + // console.groupEnd(); } if ((effect.f & INERT) !== 0) { @@ -481,11 +482,11 @@ function reconcile(state, array, anchor, flags, get_key) { if (effect !== current) { if (seen !== undefined && seen.has(effect)) { - console.log({ - effect: effect.nodes?.start.textContent, - matched: matched.map((e) => e.nodes?.start.textContent).join(' '), - stashed: stashed.map((e) => e.nodes?.start.textContent).join(' ') - }); + // console.log({ + // effect: effect.nodes?.start.textContent, + // matched: matched.map((e) => e.nodes?.start.textContent).join(' '), + // stashed: stashed.map((e) => e.nodes?.start.textContent).join(' ') + // }); if (matched.length < stashed.length) { // more efficient to move later items to the front @@ -494,37 +495,37 @@ function reconcile(state, array, anchor, flags, get_key) { prev = start.prev; - console.group('move later items to the front'); - console.log({ - effect: effect.nodes?.start.textContent, - prev: prev?.nodes?.start.textContent, - start: start?.nodes?.start.textContent, - current: current?.nodes?.start.textContent, - effect_prev: effect.prev?.nodes?.start.textContent, - effect_next: effect.next?.nodes?.start.textContent - }); + // console.group('move later items to the front'); + // console.log({ + // effect: effect.nodes?.start.textContent, + // prev: prev?.nodes?.start.textContent, + // start: start?.nodes?.start.textContent, + // current: current?.nodes?.start.textContent, + // effect_prev: effect.prev?.nodes?.start.textContent, + // effect_next: effect.next?.nodes?.start.textContent + // }); var a = matched[0]; var b = matched[matched.length - 1]; for (j = 0; j < matched.length; j += 1) { - console.group('moving', matched[j].nodes?.start.textContent); + // console.group('moving', matched[j].nodes?.start.textContent); move(matched[j], start, anchor); - console.log( - 'output:', - Array.from(document.querySelector('#output')?.childNodes) - .map((node) => { - if (node.nodeType === node.TEXT_NODE && /** @type {Text} */ (node).data === '') { - return ''; - } + // console.log( + // 'output:', + // Array.from(document.querySelector('#output')?.childNodes) + // .map((node) => { + // if (node.nodeType === node.TEXT_NODE && /** @type {Text} */ (node).data === '') { + // return ''; + // } - return node.textContent; - }) - .join(' ') - ); + // return node.textContent; + // }) + // .join(' ') + // ); - console.groupEnd(); + // console.groupEnd(); } for (j = 0; j < stashed.length; j += 1) { @@ -536,7 +537,7 @@ function reconcile(state, array, anchor, flags, get_key) { link(state, b, start); log_state(state, 'after move'); - console.groupEnd(); + // console.groupEnd(); current = start; prev = b; @@ -549,14 +550,14 @@ function reconcile(state, array, anchor, flags, get_key) { seen.delete(effect); move(effect, current, anchor); - console.group('move earlier items back'); - console.log({ - effect: effect.nodes?.start.textContent, - prev: prev?.nodes?.start.textContent, - current: current?.nodes?.start.textContent, - effect_prev: effect.prev?.nodes?.start.textContent, - effect_next: effect.next?.nodes?.start.textContent - }); + // console.group('move earlier items back'); + // console.log({ + // effect: effect.nodes?.start.textContent, + // prev: prev?.nodes?.start.textContent, + // current: current?.nodes?.start.textContent, + // effect_prev: effect.prev?.nodes?.start.textContent, + // effect_next: effect.next?.nodes?.start.textContent + // }); link(state, effect.prev, effect.next); link(state, effect, prev === null ? state.effect.first : prev.next); @@ -565,7 +566,7 @@ function reconcile(state, array, anchor, flags, get_key) { prev = effect; log_state(state, 'after move'); - console.groupEnd(); + // console.groupEnd(); } continue; @@ -576,7 +577,7 @@ function reconcile(state, array, anchor, flags, get_key) { while (current !== null && current !== effect) { (seen ??= new Set()).add(current); - console.log('stashing', current.nodes?.start.textContent); + // console.log('stashing', current.nodes?.start.textContent); stashed.push(current); current = current.next; } @@ -663,6 +664,7 @@ function reconcile(state, array, anchor, flags, get_key) { * @param {string} [message] */ function log_state(state, message = 'log_state') { + return; console.group(message); let effect = state.effect.first; @@ -762,7 +764,7 @@ function create_item(items, anchor, value, key, index, render_fn, flags, get_col function move(item, next, anchor) { if (!item.nodes) return; - // var end = item.next ? /** @type {EffectNodes} */ (item.next.nodes).start : anchor; + var node = item.nodes.start; var end = item.nodes.end; var dest = @@ -770,10 +772,6 @@ function move(item, next, anchor) { ? /** @type {EffectNodes} */ (next.nodes).start : anchor; - var node = /** @type {TemplateNode} */ (item.nodes.start); - - console.log({ node, dest, end }); - while (node !== null) { var next_node = /** @type {TemplateNode} */ (get_next_sibling(node)); dest.before(node); @@ -792,7 +790,7 @@ function move(item, next, anchor) { * @param {Effect | null} next */ function link(state, prev, next) { - console.log('link', prev?.nodes?.start.textContent, next?.nodes?.start.textContent); + // console.log('link', prev?.nodes?.start.textContent, next?.nodes?.start.textContent); if (prev === null) { state.effect.first = next; From 88a6047ca8de274d1e4d14897d3fe4cc48f8890c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 00:05:06 -0500 Subject: [PATCH 24/31] WIP --- .../src/internal/client/dom/blocks/each.js | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 9cebbc9704cb..cba0286120d3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -172,7 +172,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f hydrate_next(); } - /** @type {{ fragment: DocumentFragment | null, effect: Effect } | null} */ + /** @type {Effect | null} */ var fallback = null; // TODO: ideally we could use derived for runes mode but because of the ability @@ -194,16 +194,17 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f if (fallback !== null) { if (array.length === 0) { - if (fallback.fragment) { - anchor.before(fallback.fragment); - fallback.fragment = null; + if ((fallback.f & EFFECT_OFFSCREEN) === 0) { + console.warn('resuming fallback'); + resume_effect(fallback); } else { - resume_effect(fallback.effect); + console.warn('moving fallback onscreen'); + // TODO do we need to relink effects? + fallback.f ^= EFFECT_OFFSCREEN; + move(fallback, null, anchor); } - - effect.first = fallback.effect; } else { - pause_effect(fallback.effect, () => { + pause_effect(fallback, () => { // TODO only null out if no pending batch needs it, // otherwise re-add `fallback.fragment` and move the // effect into it @@ -211,6 +212,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f }); } } + + log_state(state, 'after reconcile'); } var effect = block(() => { @@ -287,19 +290,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f if (length === 0 && fallback_fn && !fallback) { if (first_run) { - fallback = { - fragment: null, - effect: branch(() => fallback_fn(anchor)) - }; + fallback = branch(() => fallback_fn(anchor)); } else { - var fragment = document.createDocumentFragment(); - var target = create_text(); - fragment.append(target); - - fallback = { - fragment, - effect: branch(() => fallback_fn(target)) - }; + fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text()))); + fallback.f |= EFFECT_OFFSCREEN; } } @@ -655,8 +649,6 @@ function reconcile(state, array, anchor, flags, get_key) { } }); } - - log_state(state, 'after reconcile'); } /** @@ -664,7 +656,7 @@ function reconcile(state, array, anchor, flags, get_key) { * @param {string} [message] */ function log_state(state, message = 'log_state') { - return; + // return; console.group(message); let effect = state.effect.first; From 3880b8ae22aea27037fa571d2dd26268f352613f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 00:12:59 -0500 Subject: [PATCH 25/31] tests passing --- packages/svelte/src/internal/client/dom/blocks/each.js | 9 ++++----- packages/svelte/src/internal/client/types.d.ts | 2 ++ 2 files changed, 6 insertions(+), 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 cba0286120d3..1e1086d27655 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -190,15 +190,14 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var first_run = true; function commit() { + state.fallback = fallback; reconcile(state, array, anchor, flags, get_key); if (fallback !== null) { if (array.length === 0) { if ((fallback.f & EFFECT_OFFSCREEN) === 0) { - console.warn('resuming fallback'); resume_effect(fallback); } else { - console.warn('moving fallback onscreen'); // TODO do we need to relink effects? fallback.f ^= EFFECT_OFFSCREEN; move(fallback, null, anchor); @@ -334,7 +333,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f }); /** @type {EachState} */ - var state = { effect, flags, items, outrogroups: null }; + var state = { effect, flags, items, outrogroups: null, fallback }; first_run = false; @@ -616,7 +615,7 @@ function reconcile(state, array, anchor, flags, get_key) { while (current !== null) { // If the each block isn't inert, then inert effects are currently outroing and will be removed once the transition is finished - if ((current.f & INERT) === 0) { + if ((current.f & INERT) === 0 && current !== state.fallback) { to_destroy.push(current); } current = current.next; @@ -656,7 +655,7 @@ function reconcile(state, array, anchor, flags, get_key) { * @param {string} [message] */ function log_state(state, message = 'log_state') { - // return; + return; console.group(message); let effect = state.effect.first; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 9cffc70e3c46..d005d3dc3786 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -86,6 +86,8 @@ export type EachState = { items: Map; /** all outro groups that this item is a part of */ outrogroups: Set | null; + /** `{:else}` effect */ + fallback: Effect | null; }; export type EachItem = { From f4cb922c9c3cde6718cfafadb4612ad6d3043bd4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 00:14:50 -0500 Subject: [PATCH 26/31] tidy up --- .../src/internal/client/dom/blocks/each.js | 126 ------------------ 1 file changed, 126 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 1e1086d27655..270c07e0e43f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -135,8 +135,6 @@ function destroy_items(state, to_destroy) { for (var i = 0; i < to_destroy.length; i++) { destroy_effect(to_destroy[i]); } - - log_state(state, 'after destroy_items'); } /** @type {TemplateNode} */ @@ -211,8 +209,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f }); } } - - log_state(state, 'after reconcile'); } var effect = block(() => { @@ -386,8 +382,6 @@ function reconcile(state, array, anchor, flags, get_key) { /** @type {number} */ var i; - log_state(state, 'before reconcile'); - if (is_animated) { for (i = 0; i < length; i += 1) { value = array[i]; @@ -409,8 +403,6 @@ function reconcile(state, array, anchor, flags, get_key) { effect = /** @type {EachItem} */ (items.get(key)).e; - // console.log({ key }); - if (state.outrogroups !== null) { for (const group of state.outrogroups) { if (group.effects.has(effect)) { @@ -423,21 +415,9 @@ function reconcile(state, array, anchor, flags, get_key) { if ((effect.f & EFFECT_OFFSCREEN) !== 0) { effect.f ^= EFFECT_OFFSCREEN; - // console.group('insert offscreen effect'); - // console.log({ - // effect: effect.nodes?.start.textContent, - // prev: prev?.nodes?.start.textContent, - // next: next?.nodes?.start.textContent, - // current: current?.nodes?.start.textContent, - // effect_prev: effect.prev?.nodes?.start.textContent, - // effect_next: effect.next?.nodes?.start.textContent - // }); - if (effect === current) { - // console.warn('>>>> is current', effect.nodes?.start.textContent); move(effect, null, anchor); } else { - // console.warn('>>>> is NOT current', effect.nodes?.start.textContent); var next = prev ? prev.next : current; if (effect === state.effect.last) { @@ -446,10 +426,8 @@ function reconcile(state, array, anchor, flags, get_key) { if (effect.prev) effect.prev.next = effect.next; if (effect.next) effect.next.prev = effect.prev; - log_state(state, 'after yoink'); link(state, prev, effect); link(state, effect, next); - log_state(state, 'after link'); move(effect, next, anchor); prev = effect; @@ -460,9 +438,6 @@ function reconcile(state, array, anchor, flags, get_key) { current = prev.next; continue; } - - log_state(state, 'after insert'); - // console.groupEnd(); } if ((effect.f & INERT) !== 0) { @@ -475,12 +450,6 @@ function reconcile(state, array, anchor, flags, get_key) { if (effect !== current) { if (seen !== undefined && seen.has(effect)) { - // console.log({ - // effect: effect.nodes?.start.textContent, - // matched: matched.map((e) => e.nodes?.start.textContent).join(' '), - // stashed: stashed.map((e) => e.nodes?.start.textContent).join(' ') - // }); - if (matched.length < stashed.length) { // more efficient to move later items to the front var start = stashed[0]; @@ -488,37 +457,11 @@ function reconcile(state, array, anchor, flags, get_key) { prev = start.prev; - // console.group('move later items to the front'); - // console.log({ - // effect: effect.nodes?.start.textContent, - // prev: prev?.nodes?.start.textContent, - // start: start?.nodes?.start.textContent, - // current: current?.nodes?.start.textContent, - // effect_prev: effect.prev?.nodes?.start.textContent, - // effect_next: effect.next?.nodes?.start.textContent - // }); - var a = matched[0]; var b = matched[matched.length - 1]; for (j = 0; j < matched.length; j += 1) { - // console.group('moving', matched[j].nodes?.start.textContent); move(matched[j], start, anchor); - - // console.log( - // 'output:', - // Array.from(document.querySelector('#output')?.childNodes) - // .map((node) => { - // if (node.nodeType === node.TEXT_NODE && /** @type {Text} */ (node).data === '') { - // return ''; - // } - - // return node.textContent; - // }) - // .join(' ') - // ); - - // console.groupEnd(); } for (j = 0; j < stashed.length; j += 1) { @@ -529,9 +472,6 @@ function reconcile(state, array, anchor, flags, get_key) { link(state, prev, a); link(state, b, start); - log_state(state, 'after move'); - // console.groupEnd(); - current = start; prev = b; i -= 1; @@ -543,23 +483,11 @@ function reconcile(state, array, anchor, flags, get_key) { seen.delete(effect); move(effect, current, anchor); - // console.group('move earlier items back'); - // console.log({ - // effect: effect.nodes?.start.textContent, - // prev: prev?.nodes?.start.textContent, - // current: current?.nodes?.start.textContent, - // effect_prev: effect.prev?.nodes?.start.textContent, - // effect_next: effect.next?.nodes?.start.textContent - // }); - link(state, effect.prev, effect.next); link(state, effect, prev === null ? state.effect.first : prev.next); link(state, prev, effect); prev = effect; - - log_state(state, 'after move'); - // console.groupEnd(); } continue; @@ -570,7 +498,6 @@ function reconcile(state, array, anchor, flags, get_key) { while (current !== null && current !== effect) { (seen ??= new Set()).add(current); - // console.log('stashing', current.nodes?.start.textContent); stashed.push(current); current = current.next; } @@ -650,59 +577,6 @@ function reconcile(state, array, anchor, flags, get_key) { } } -/** - * @param {EachState} state - * @param {string} [message] - */ -function log_state(state, message = 'log_state') { - return; - console.group(message); - - let effect = state.effect.first; - - if (effect) { - let effects = [effect]; - while ((effect = effect.next)) { - if (effects.includes(effect)) { - throw new Error('items loop'); - } - - effects.push(effect); - } - - for (let i = 0; i < effects.length; i += 1) { - let effect = effects[i]; - - let text = effect.nodes?.start?.textContent ?? '???'; - - if (effect === state.effect.first) text += ' (FIRST)'; - if (effect === state.effect.last) text += ' (LAST)'; - - console.log( - `%c${text}`, - `color: ${(effect.f & EFFECT_OFFSCREEN) !== 0 ? 'magenta' : (effect.f & INERT) !== 0 ? 'grey' : 'black'}` - ); - } - } else { - console.log('no effects'); - } - - console.log( - 'output:', - Array.from(document.querySelector('#output')?.childNodes) - .map((node) => { - if (node.nodeType === node.TEXT_NODE && /** @type {Text} */ (node).data === '') { - return ''; - } - - return node.textContent; - }) - .join(' ') - ); - - console.groupEnd(); -} - /** * @template V * @param {Map} items From fb002747abde30999f4c94a4f22309aac87adf22 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 00:21:05 -0500 Subject: [PATCH 27/31] tidy up --- .../src/internal/client/dom/blocks/each.js | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 270c07e0e43f..d1056b681b7f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -75,7 +75,7 @@ function pause_effects(state, to_destroy, controlled_anchor) { if (group.remaining === 0) { var groups = /** @type {Set} */ (state.outrogroups); - destroy_items(state, Array.from(group.effects)); + destroy_effects(Array.from(group.effects)); groups.delete(group); if (groups.size === 0) { @@ -112,7 +112,7 @@ function pause_effects(state, to_destroy, controlled_anchor) { return; } - destroy_items(state, to_destroy); + destroy_effects(to_destroy); } else { group = { remaining, @@ -124,12 +124,9 @@ function pause_effects(state, to_destroy, controlled_anchor) { } /** - * Pause multiple effects simultaneously, and coordinate their - * subsequent destruction. Used in each blocks - * @param {EachState} state * @param {Effect[]} to_destroy */ -function destroy_items(state, to_destroy) { +function destroy_effects(to_destroy) { // TODO only destroy effects if no pending batch needs them. otherwise, // just re-add the `EFFECT_OFFSCREEN` flag for (var i = 0; i < to_destroy.length; i++) { @@ -518,7 +515,7 @@ function reconcile(state, array, anchor, flags, get_key) { if (state.outrogroups !== null) { for (const group of state.outrogroups) { if (group.remaining === 0) { - destroy_items(state, Array.from(group.effects)); + destroy_effects(Array.from(group.effects)); state.outrogroups?.delete(group); } } @@ -545,6 +542,7 @@ function reconcile(state, array, anchor, flags, get_key) { if ((current.f & INERT) === 0 && current !== state.fallback) { to_destroy.push(current); } + current = current.next; } @@ -622,15 +620,15 @@ function create_item(items, anchor, value, key, index, render_fn, flags, get_col } /** - * @param {Effect} item + * @param {Effect} effect * @param {Effect | null} next * @param {Text | Element | Comment} anchor */ -function move(item, next, anchor) { - if (!item.nodes) return; +function move(effect, next, anchor) { + if (!effect.nodes) return; - var node = item.nodes.start; - var end = item.nodes.end; + var node = effect.nodes.start; + var end = effect.nodes.end; var dest = next && (next.f & EFFECT_OFFSCREEN) === 0 @@ -655,8 +653,6 @@ function move(item, next, anchor) { * @param {Effect | null} next */ function link(state, prev, next) { - // console.log('link', prev?.nodes?.start.textContent, next?.nodes?.start.textContent); - if (prev === null) { state.effect.first = next; } else { From 9baca7aa7ea2bef1f7c17848e319626fb2e0d703 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 00:22:03 -0500 Subject: [PATCH 28/31] note to self --- packages/svelte/src/internal/client/dom/blocks/each.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index d1056b681b7f..602858a81ad3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -41,6 +41,9 @@ import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; +// When making substantive changes to this file, validate them with the each block stress test: +// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b + /** * @param {any} _ * @param {number} i From 79cd5c418957d97108185881f4a7b148f40e1897 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 00:51:46 -0500 Subject: [PATCH 29/31] tidy up --- .../svelte/src/internal/client/dom/blocks/each.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 602858a81ad3..0cc5d0ce0994 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -107,15 +107,9 @@ function pause_effects(state, to_destroy, controlled_anchor) { parent_node.append(anchor); state.items.clear(); - - for (i = 0; i < length; i++) { - destroy_effect(to_destroy[i]); - } - - return; } - destroy_effects(to_destroy); + destroy_effects(to_destroy, !fast_path); } else { group = { remaining, @@ -128,12 +122,13 @@ function pause_effects(state, to_destroy, controlled_anchor) { /** * @param {Effect[]} to_destroy + * @param {boolean} remove_dom */ -function destroy_effects(to_destroy) { +function destroy_effects(to_destroy, remove_dom = true) { // TODO only destroy effects if no pending batch needs them. otherwise, // just re-add the `EFFECT_OFFSCREEN` flag for (var i = 0; i < to_destroy.length; i++) { - destroy_effect(to_destroy[i]); + destroy_effect(to_destroy[i], remove_dom); } } @@ -196,7 +191,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f if ((fallback.f & EFFECT_OFFSCREEN) === 0) { resume_effect(fallback); } else { - // TODO do we need to relink effects? fallback.f ^= EFFECT_OFFSCREEN; move(fallback, null, anchor); } From 756c3501c4732628317c5ad01bac022cae9df0d3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 10:34:42 -0500 Subject: [PATCH 30/31] fix --- .../src/internal/client/dom/blocks/each.js | 25 ++++++++++--------- .../svelte/src/internal/client/types.d.ts | 4 +-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 0cc5d0ce0994..f302d6d7aadf 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -69,16 +69,19 @@ function pause_effects(state, to_destroy, controlled_anchor) { var remaining = to_destroy.length; for (var i = 0; i < length; i++) { + let effect = to_destroy[i]; + pause_effect( - to_destroy[i], + effect, () => { if (group) { - group.remaining -= 1; + group.pending.delete(effect); + group.done.add(effect); - if (group.remaining === 0) { + if (group.pending.size === 0) { var groups = /** @type {Set} */ (state.outrogroups); - destroy_effects(Array.from(group.effects)); + destroy_effects(array_from(group.done)); groups.delete(group); if (groups.size === 0) { @@ -112,8 +115,8 @@ function pause_effects(state, to_destroy, controlled_anchor) { destroy_effects(to_destroy, !fast_path); } else { group = { - remaining, - effects: new Set(to_destroy) + pending: new Set(to_destroy), + done: new Set() }; (state.outrogroups ??= new Set()).add(group); @@ -399,10 +402,8 @@ function reconcile(state, array, anchor, flags, get_key) { if (state.outrogroups !== null) { for (const group of state.outrogroups) { - if (group.effects.has(effect)) { - group.remaining -= 1; - group.effects.delete(effect); - } + group.pending.delete(effect); + group.done.delete(effect); } } @@ -511,8 +512,8 @@ function reconcile(state, array, anchor, flags, get_key) { if (state.outrogroups !== null) { for (const group of state.outrogroups) { - if (group.remaining === 0) { - destroy_effects(Array.from(group.effects)); + if (group.pending.size === 0) { + destroy_effects(array_from(group.done)); state.outrogroups?.delete(group); } } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index d005d3dc3786..443c21010e98 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -73,8 +73,8 @@ export type TemplateNode = Text | Element | Comment; export type Dom = TemplateNode | TemplateNode[]; export type EachOutroGroup = { - remaining: number; - effects: Set; + pending: Set; + done: Set; }; export type EachState = { From a64d3918da0ed12e465ad23df07ee4c548b6a8b3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 3 Dec 2025 11:21:47 -0500 Subject: [PATCH 31/31] add stress test to repo --- .../src/internal/client/dom/blocks/each.js | 1 + .../tests/manual/each-stress-test/main.svelte | 194 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 packages/svelte/tests/manual/each-stress-test/main.svelte diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index f302d6d7aadf..07545e81ccd5 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -43,6 +43,7 @@ import { current_batch } from '../../reactivity/batch.js'; // When making substantive changes to this file, validate them with the each block stress test: // https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b +// This test also exists in this repo, as `packages/svelte/tests/manual/each-stress-test` /** * @param {any} _ diff --git a/packages/svelte/tests/manual/each-stress-test/main.svelte b/packages/svelte/tests/manual/each-stress-test/main.svelte new file mode 100644 index 000000000000..cb696128448c --- /dev/null +++ b/packages/svelte/tests/manual/each-stress-test/main.svelte @@ -0,0 +1,194 @@ + + +

each block stress test

+ + + + + +
+ random + + + +
+ +
+ presets + + {#each presets as preset, index} + + {/each} +
+ +
{ + e.preventDefault(); + test(e.currentTarget.querySelector('input').value); +}}> +
+ input + +
+
+ +
+ {#each list as c (c)} + ({c}:{n}) + {:else} + (fallback) + {/each} +
+ +{#if error} +

{error}

+{/if} + +