From 01db9bfa1031d095600d9b1b322135eb3fde1c58 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 10:50:29 -0500 Subject: [PATCH 01/28] state.items -> state.onscreen --- .../src/internal/client/dom/blocks/each.js | 16 +++++++--------- packages/svelte/src/internal/client/types.d.ts | 2 +- 2 files changed, 8 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 a0fae3713305..965efa3d9c68 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -71,8 +71,6 @@ export function index(_, i) { * @param {null | Node} controlled_anchor */ function pause_effects(state, items, controlled_anchor) { - var items_map = state.items; - /** @type {TransitionManager[]} */ var transitions = []; var length = items.length; @@ -90,7 +88,7 @@ function pause_effects(state, items, controlled_anchor) { ); clear_text_content(parent_node); parent_node.append(/** @type {Element} */ (controlled_anchor)); - items_map.clear(); + state.onscreen.clear(); link(state, items[0].prev, items[length - 1].next); } @@ -98,7 +96,7 @@ function pause_effects(state, items, controlled_anchor) { for (var i = 0; i < length; i++) { var item = items[i]; if (!is_controlled) { - items_map.delete(item.k); + state.onscreen.delete(item.k); link(state, item.prev, item.next); } destroy_effect(item.e, !is_controlled); @@ -120,7 +118,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var anchor = node; /** @type {EachState} */ - var state = { flags, items: new Map(), first: null }; + var state = { flags, onscreen: new Map(), first: null }; var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; @@ -252,7 +250,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f flags, get_collection ); - state.items.set(key, item); + state.onscreen.set(key, item); prev = item; } @@ -276,7 +274,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f value = array[i]; key = get_key(value, i); - var existing = state.items.get(key) ?? offscreen_items.get(key); + var existing = state.onscreen.get(key) ?? offscreen_items.get(key); if (existing) { // update before reconciliation, to trigger any async updates @@ -304,7 +302,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f keys.add(key); } - for (const [key, item] of state.items) { + for (const [key, item] of state.onscreen) { if (!keys.has(key)) { batch.skipped_effects.add(item.e); } @@ -364,7 +362,7 @@ function reconcile( var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; var length = array.length; - var items = state.items; + var items = state.onscreen; var first = state.first; var current = first; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index deb3e829860f..b8129992ecb0 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -67,7 +67,7 @@ export type EachState = { /** flags */ flags: number; /** a key -> item lookup */ - items: Map; + onscreen: Map; /** head of the linked list of items */ first: EachItem | null; }; From 3c133bb5ce97a71a5585ab80959629e0536e24f3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 10:53:10 -0500 Subject: [PATCH 02/28] offscreen_items -> state.offscreen --- .../src/internal/client/dom/blocks/each.js | 42 ++++--------------- .../svelte/src/internal/client/types.d.ts | 4 +- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 965efa3d9c68..3dffc011de2c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -118,7 +118,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var anchor = node; /** @type {EachState} */ - var state = { flags, onscreen: new Map(), first: null }; + var state = { flags, onscreen: new Map(), offscreen: new Map(), first: null }; var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; @@ -139,9 +139,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var was_empty = false; - /** @type {Map} */ - var offscreen_items = new Map(); - // TODO: ideally we could use derived for runes mode but because of the ability // to use a store which can be mutated, we can't do that here as mutating a store // will still result in the collection array being the same from the store @@ -158,17 +155,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var each_effect; function commit() { - reconcile( - each_effect, - array, - state, - offscreen_items, - anchor, - render_fn, - flags, - get_key, - get_collection - ); + reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection); if (fallback_fn !== null) { if (array.length === 0) { @@ -274,7 +261,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f value = array[i]; key = get_key(value, i); - var existing = state.onscreen.get(key) ?? offscreen_items.get(key); + var existing = state.onscreen.get(key) ?? state.offscreen.get(key); if (existing) { // update before reconciliation, to trigger any async updates @@ -296,7 +283,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f true ); - offscreen_items.set(key, item); + state.offscreen.set(key, item); } keys.add(key); @@ -339,7 +326,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f * @param {Effect} each_effect * @param {Array} array * @param {EachState} state - * @param {Map} offscreen_items * @param {Element | Comment | Text} anchor * @param {(anchor: Node, item: MaybeSource, index: number | Source, collection: () => V[]) => void} render_fn * @param {number} flags @@ -347,17 +333,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f * @param {() => V[]} get_collection * @returns {void} */ -function reconcile( - each_effect, - array, - state, - offscreen_items, - anchor, - render_fn, - flags, - get_key, - get_collection -) { +function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection) { var is_animated = (flags & EACH_IS_ANIMATED) !== 0; var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; @@ -413,10 +389,10 @@ function reconcile( item = items.get(key); if (item === undefined) { - var pending = offscreen_items.get(key); + var pending = state.offscreen.get(key); if (pending !== undefined) { - offscreen_items.delete(key); + state.offscreen.delete(key); items.set(key, pending); var next = prev ? prev.next : current; @@ -576,11 +552,11 @@ function reconcile( each_effect.first = state.first && state.first.e; each_effect.last = prev && prev.e; - for (var unused of offscreen_items.values()) { + for (var unused of state.offscreen.values()) { destroy_effect(unused.e); } - offscreen_items.clear(); + state.offscreen.clear(); } /** diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index b8129992ecb0..fdd4741377ef 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -66,8 +66,10 @@ export type Dom = TemplateNode | TemplateNode[]; export type EachState = { /** flags */ flags: number; - /** a key -> item lookup */ + /** items that are currently onscreen */ onscreen: Map; + /** items that are currently offscreen */ + offscreen: Map; /** head of the linked list of items */ first: EachItem | null; }; From e075fb2edecf2ad7376457624249d0dae23ae76d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 15:29:34 -0500 Subject: [PATCH 03/28] WIP --- .../src/internal/client/dom/blocks/each.js | 156 ++++++++---------- .../src/internal/client/reactivity/effects.js | 3 + 2 files changed, 73 insertions(+), 86 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 3dffc011de2c..cd143d57716f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -42,6 +42,7 @@ import { active_effect, get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; +import { log_effect_tree, root } from '../../dev/debug.js'; /** * The row of a keyed each block that is currently updating. We track this @@ -151,9 +152,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f /** @type {V[]} */ var array; - /** @type {Effect} */ - var each_effect; - function commit() { reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection); @@ -172,10 +170,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } - block(() => { - // store a reference to the effect so that we can update the start/end nodes in reconciliation - each_effect ??= /** @type {Effect} */ (active_effect); + var first_run = true; + var each_effect = block(() => { array = /** @type {V[]} */ (get(each_array)); var length = array.length; @@ -202,31 +199,41 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } - // this is separate to the previous block because `hydrating` might change - if (hydrating) { - /** @type {EachItem | null} */ - var prev = null; - - /** @type {EachItem} */ - var item; - - for (var i = 0; i < length; i++) { - if ( - hydrate_node.nodeType === COMMENT_NODE && - /** @type {Comment} */ (hydrate_node).data === HYDRATION_END - ) { - // The server rendered fewer items than expected, - // so break out and continue appending non-hydrated items - anchor = /** @type {Comment} */ (hydrate_node); - mismatch = true; - set_hydrating(false); - break; + 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) { + if ( + hydrating && + hydrate_node.nodeType === COMMENT_NODE && + /** @type {Comment} */ (hydrate_node).data === HYDRATION_END + ) { + // The server rendered fewer items than expected, + // so break out and continue appending non-hydrated items + anchor = /** @type {Comment} */ (hydrate_node); + mismatch = true; + set_hydrating(false); + break; + } + + var value = array[i]; + var key = get_key(value, i); + + var item = first_run ? null : state.onscreen.get(key) ?? state.offscreen.get(key); + + if (item) { + // update before reconciliation, to trigger any async updates + if ((flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0) { + update_item(item, value, i, flags); } - var value = array[i]; - var key = get_key(value, i); + batch.skipped_effects.delete(item.e); + } else { + console.log('creating', key); item = create_item( - hydrate_node, + first_run ? (hydrating ? hydrate_node : anchor) : null, state, prev, null, @@ -235,66 +242,40 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f i, render_fn, flags, - get_collection + get_collection, + defer ); - state.onscreen.set(key, item); - - prev = item; - } - - // remove excess nodes - if (length > 0) { - set_hydrate_node(skip_nodes()); - } - } - if (hydrating) { - if (length === 0 && fallback_fn) { - fallback = branch(() => fallback_fn(anchor)); - } - } else { - if (should_defer_append()) { - var keys = new Set(); - var batch = /** @type {Batch} */ (current_batch); - - for (i = 0; i < length; i += 1) { - value = array[i]; - key = get_key(value, i); - - var existing = state.onscreen.get(key) ?? state.offscreen.get(key); - - if (existing) { - // update before reconciliation, to trigger any async updates - if ((flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0) { - update_item(existing, value, i, flags); - } + if (first_run) { + if (prev === null) { + state.first = item; } else { - item = create_item( - null, - state, - null, - null, - value, - key, - i, - render_fn, - flags, - get_collection, - true - ); - - state.offscreen.set(key, item); + prev.next = item; } - keys.add(key); + prev = item; + state.onscreen.set(key, item); + } else { + state.offscreen.set(key, item); } + } - for (const [key, item] of state.onscreen) { - if (!keys.has(key)) { - batch.skipped_effects.add(item.e); - } - } + keys.add(key); + } + + // remove excess nodes + if (hydrating && length > 0) { + set_hydrate_node(skip_nodes()); + } + for (const [key, item] of state.onscreen) { + if (!keys.has(key)) { + batch.skipped_effects.add(item.e); + } + } + + if (!first_run) { + if (defer) { batch.oncommit(commit); } else { commit(); @@ -315,6 +296,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f get(each_array); }); + first_run = false; + if (hydrating) { anchor = hydrate_node; } @@ -338,7 +321,7 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; var length = array.length; - var items = state.onscreen; + var onscreen = state.onscreen; var first = state.first; var current = first; @@ -373,7 +356,7 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); - item = items.get(key); + item = onscreen.get(key); if (item !== undefined) { item.a?.measure(); @@ -386,14 +369,14 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, value = array[i]; key = get_key(value, i); - item = items.get(key); + item = onscreen.get(key); if (item === undefined) { var pending = state.offscreen.get(key); if (pending !== undefined) { state.offscreen.delete(key); - items.set(key, pending); + onscreen.set(key, pending); var next = prev ? prev.next : current; @@ -419,7 +402,7 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, ); } - items.set(key, prev); + onscreen.set(key, prev); matched = []; stashed = []; @@ -643,13 +626,14 @@ function create_item( fragment.append((anchor = create_text())); } - item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection), hydrating); + item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection)); item.e.prev = prev && prev.e; item.e.next = next && next.e; if (prev === null) { if (!deferred) { + // TODO move this into block effect? state.first = item; } } else { diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 5d7c0ef871fd..7b5fb1fc36ea 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -171,6 +171,8 @@ function create_effect(type, fn, sync, push = true) { (derived.effects ??= []).push(e); } } + } else { + console.trace('not pushing'); } return effect; @@ -386,6 +388,7 @@ export function block(fn, flags = 0) { return effect; } +// TODO i think we don't need `push` any more? /** * @param {(() => void)} fn * @param {boolean} [push] From 8069458e25a3deafe5b595b584e49aee194aed0b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 17:20:09 -0500 Subject: [PATCH 04/28] WIP --- packages/svelte/src/internal/client/dom/blocks/each.js | 9 +++++---- 1 file changed, 5 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 cd143d57716f..63c578858a92 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -81,6 +81,7 @@ function pause_effects(state, items, controlled_anchor) { } var is_controlled = length > 0 && transitions.length === 0 && controlled_anchor !== null; + // If we have a controlled anchor, it means that the each block is inside a single // DOM element, so we can apply a fast-path for clearing the contents of the element. if (is_controlled) { @@ -90,7 +91,6 @@ function pause_effects(state, items, controlled_anchor) { clear_text_content(parent_node); parent_node.append(/** @type {Element} */ (controlled_anchor)); state.onscreen.clear(); - link(state, items[0].prev, items[length - 1].next); } run_out_transitions(transitions, () => { @@ -98,11 +98,13 @@ function pause_effects(state, items, controlled_anchor) { var item = items[i]; if (!is_controlled) { state.onscreen.delete(item.k); - link(state, item.prev, item.next); } + destroy_effect(item.e, !is_controlled); } }); + + link(state, items[0].prev, items[length - 1].next); } /** @@ -231,7 +233,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f batch.skipped_effects.delete(item.e); } else { - console.log('creating', key); item = create_item( first_run ? (hydrating ? hydrate_node : anchor) : null, state, @@ -634,7 +635,7 @@ function create_item( if (prev === null) { if (!deferred) { // TODO move this into block effect? - state.first = item; + // state.first = item; } } else { prev.next = item; From 78736b7257ff0baaa3e1a0ef0f36abdd7a19572d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 19:43:38 -0500 Subject: [PATCH 05/28] WIP --- .../src/internal/client/dom/blocks/each.js | 36 +++++++++++++------ 1 file changed, 26 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 63c578858a92..e3673fe2d5b7 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -137,7 +137,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f hydrate_next(); } - /** @type {Effect | null} */ + /** @type {{ fragment: DocumentFragment | null, effect: Effect } | null} */ var fallback = null; var was_empty = false; @@ -157,18 +157,23 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f function commit() { reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection); - if (fallback_fn !== null) { - if (array.length === 0) { - if (fallback) { - resume_effect(fallback); + if (array.length === 0) { + if (fallback !== null) { + if (fallback.fragment) { + anchor.before(fallback.fragment); + fallback.fragment = null; } else { - fallback = branch(() => fallback_fn(anchor)); + // TODO if this was + resume_effect(fallback.effect); } - } else if (fallback !== null) { - pause_effect(fallback, () => { - fallback = null; - }); } + } else if (fallback !== null) { + pause_effect(fallback.effect, () => { + // TODO only null out if no pending batch needs it, + // otherwise re-add `fallback.fragment` and move the + // effect into it + fallback = null; + }); } } @@ -264,6 +269,17 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f keys.add(key); } + if (length === 0 && fallback_fn && !fallback) { + var fragment = document.createDocumentFragment(); + var target = create_text(); + fragment.append(target); + + fallback = { + fragment, + effect: branch(() => fallback_fn(target)) + }; + } + // remove excess nodes if (hydrating && length > 0) { set_hydrate_node(skip_nodes()); From e3ca7a0a7f17a866677891415600ff8f99b4242a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 20:20:07 -0500 Subject: [PATCH 06/28] WIP --- .../src/internal/client/dom/blocks/each.js | 55 +++++++++++-------- 1 file changed, 32 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 e3673fe2d5b7..85f6d1b01528 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -157,8 +157,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f function commit() { reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection); - if (array.length === 0) { - if (fallback !== null) { + if (fallback !== null) { + if (array.length === 0) { if (fallback.fragment) { anchor.before(fallback.fragment); fallback.fragment = null; @@ -166,14 +166,16 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f // TODO if this was resume_effect(fallback.effect); } + + each_effect.first = fallback.effect; + } else { + pause_effect(fallback.effect, () => { + // TODO only null out if no pending batch needs it, + // otherwise re-add `fallback.fragment` and move the + // effect into it + fallback = null; + }); } - } else if (fallback !== null) { - pause_effect(fallback.effect, () => { - // TODO only null out if no pending batch needs it, - // otherwise re-add `fallback.fragment` and move the - // effect into it - fallback = null; - }); } } @@ -183,12 +185,12 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f array = /** @type {V[]} */ (get(each_array)); var length = array.length; - if (was_empty && length === 0) { - // ignore updates if the array is empty, - // and it already was empty on previous run - return; - } - was_empty = length === 0; + // if (was_empty && length === 0) { + // // ignore updates if the array is empty, + // // and it already was empty on previous run + // return; + // } + // was_empty = length === 0; /** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ let mismatch = false; @@ -270,14 +272,21 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } if (length === 0 && fallback_fn && !fallback) { - var fragment = document.createDocumentFragment(); - var target = create_text(); - fragment.append(target); - - fallback = { - fragment, - effect: branch(() => fallback_fn(target)) - }; + if (first_run) { + fallback = { + fragment: null, + effect: branch(() => fallback_fn(anchor)) + }; + } else { + var fragment = document.createDocumentFragment(); + var target = create_text(); + fragment.append(target); + + fallback = { + fragment, + effect: branch(() => fallback_fn(target)) + }; + } } // remove excess nodes From 1459e255fe39ee864d6e329525d3fa3b53db20dc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 21:29:16 -0500 Subject: [PATCH 07/28] WIP --- .../src/internal/client/dom/blocks/each.js | 16 ++++++++++++---- 1 file changed, 12 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 85f6d1b01528..de7160b6ed54 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -348,6 +348,7 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, var length = array.length; var onscreen = state.onscreen; + var offscreen = state.offscreen; var first = state.first; var current = first; @@ -398,10 +399,10 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, item = onscreen.get(key); if (item === undefined) { - var pending = state.offscreen.get(key); + var pending = offscreen.get(key); if (pending !== undefined) { - state.offscreen.delete(key); + offscreen.delete(key); onscreen.set(key, pending); var next = prev ? prev.next : current; @@ -561,11 +562,18 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, each_effect.first = state.first && state.first.e; each_effect.last = prev && prev.e; - for (var unused of state.offscreen.values()) { + if (prev) { + // TODO i think this is wrong... the offscreen items need to be linked, + // so that they all update correctly. the last onscreen item should link + // to the first offscreen item, etc + prev.e.next = null; + } + + for (var unused of offscreen.values()) { destroy_effect(unused.e); } - state.offscreen.clear(); + offscreen.clear(); } /** From 8fb43a9f0279750a46b0a4b1696f4dcee040c445 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 21:31:58 -0500 Subject: [PATCH 08/28] WIP --- .../src/internal/client/dom/blocks/each.js | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index de7160b6ed54..9bb5a7da0c68 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -38,11 +38,10 @@ import { source, mutable_source, internal_set } from '../../reactivity/sources.j import { array_from, is_array } from '../../../shared/utils.js'; import { COMMENT_NODE, INERT } from '#client/constants'; import { queue_micro_task } from '../task.js'; -import { active_effect, get } from '../../runtime.js'; +import { get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; -import { log_effect_tree, root } from '../../dev/debug.js'; /** * The row of a keyed each block that is currently updating. We track this @@ -140,8 +139,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f /** @type {{ fragment: DocumentFragment | null, effect: Effect } | null} */ var fallback = null; - var was_empty = false; - // TODO: ideally we could use derived for runes mode but because of the ability // to use a store which can be mutated, we can't do that here as mutating a store // will still result in the collection array being the same from the store @@ -154,6 +151,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f /** @type {V[]} */ var array; + var first_run = true; + function commit() { reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection); @@ -179,19 +178,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } - var first_run = true; - var each_effect = block(() => { array = /** @type {V[]} */ (get(each_array)); var length = array.length; - // if (was_empty && length === 0) { - // // ignore updates if the array is empty, - // // and it already was empty on previous run - // return; - // } - // was_empty = length === 0; - /** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ let mismatch = false; From 2b70f3f4748cafe2a7ac50a6dbcfd4bb24abbab2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 21:35:17 -0500 Subject: [PATCH 09/28] WIP --- .../src/internal/client/dom/blocks/each.js | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 9bb5a7da0c68..70a9f0fa27a3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -389,35 +389,21 @@ function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, item = onscreen.get(key); if (item === undefined) { - var pending = offscreen.get(key); + item = offscreen.get(key); - if (pending !== undefined) { - offscreen.delete(key); - onscreen.set(key, pending); + if (item === undefined) { + throw new Error('this should be impossible'); + } - var next = prev ? prev.next : current; + offscreen.delete(key); - link(state, prev, pending); - link(state, pending, next); + var next = prev ? prev.next : current; - move(pending, next, anchor); - prev = pending; - } else { - var child_anchor = current ? /** @type {TemplateNode} */ (current.e.nodes_start) : anchor; + link(state, prev, item); + link(state, item, next); - prev = create_item( - child_anchor, - state, - prev, - prev === null ? state.first : prev.next, - value, - key, - i, - render_fn, - flags, - get_collection - ); - } + move(item, next, anchor); + prev = item; onscreen.set(key, prev); From 369023389cae89749063a07a164f7547085fac51 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 21:41:50 -0500 Subject: [PATCH 10/28] WIP --- .../src/internal/client/dom/blocks/each.js | 40 +++---------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 70a9f0fa27a3..810c3158ac39 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -201,7 +201,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) { if ( @@ -232,16 +231,13 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } else { item = create_item( first_run ? (hydrating ? hydrate_node : anchor) : null, - state, prev, - null, value, key, i, render_fn, flags, - get_collection, - defer + get_collection ); if (first_run) { @@ -291,7 +287,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } if (!first_run) { - if (defer) { + if (should_defer_append()) { batch.oncommit(commit); } else { commit(); @@ -574,31 +570,16 @@ function update_item(item, value, index, type) { /** * @template V * @param {Node | null} anchor - * @param {EachState} state * @param {EachItem | null} prev - * @param {EachItem | null} next * @param {V} value * @param {unknown} key * @param {number} index * @param {(anchor: Node, item: V | Source, index: number | Value, collection: () => V[]) => void} render_fn * @param {number} flags * @param {() => V[]} get_collection - * @param {boolean} [deferred] * @returns {EachItem} */ -function create_item( - anchor, - state, - prev, - next, - value, - key, - index, - render_fn, - flags, - get_collection, - deferred -) { +function create_item(anchor, prev, value, key, index, render_fn, flags, get_collection) { var previous_each_item = current_each_item; var reactive = (flags & EACH_ITEM_REACTIVE) !== 0; var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0; @@ -625,7 +606,7 @@ function create_item( // @ts-expect-error e: null, prev, - next + next: null }; current_each_item = item; @@ -639,23 +620,12 @@ function create_item( item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection)); item.e.prev = prev && prev.e; - item.e.next = next && next.e; - if (prev === null) { - if (!deferred) { - // TODO move this into block effect? - // state.first = item; - } - } else { + if (prev !== null) { prev.next = item; prev.e.next = item.e; } - if (next !== null) { - next.prev = item; - next.e.prev = item.e; - } - return item; } finally { current_each_item = previous_each_item; From 32cefa280b5895911eefdc252c57f6f35debc77f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 12 Nov 2025 21:54:47 -0500 Subject: [PATCH 11/28] always push --- .../src/internal/client/reactivity/effects.js | 73 +++++++++---------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 7b5fb1fc36ea..68294ef8863f 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -80,10 +80,9 @@ function push_effect(effect, parent_effect) { * @param {number} type * @param {null | (() => void | (() => void))} fn * @param {boolean} sync - * @param {boolean} push * @returns {Effect} */ -function create_effect(type, fn, sync, push = true) { +function create_effect(type, fn, sync) { var parent = active_effect; if (DEV) { @@ -133,46 +132,42 @@ function create_effect(type, fn, sync, push = true) { schedule_effect(effect); } - if (push) { - /** @type {Effect | null} */ - var e = effect; + /** @type {Effect | null} */ + var e = effect; - // if an effect has already ran and doesn't need to be kept in the tree - // (because it won't re-run, has no DOM, and has no teardown etc) - // then we skip it and go to its child (if any) - if ( - sync && - e.deps === null && - e.teardown === null && - e.nodes_start === null && - e.first === e.last && // either `null`, or a singular child - (e.f & EFFECT_PRESERVED) === 0 - ) { - e = e.first; - if ((type & BLOCK_EFFECT) !== 0 && (type & EFFECT_TRANSPARENT) !== 0 && e !== null) { - e.f |= EFFECT_TRANSPARENT; - } + // if an effect has already ran and doesn't need to be kept in the tree + // (because it won't re-run, has no DOM, and has no teardown etc) + // then we skip it and go to its child (if any) + if ( + sync && + e.deps === null && + e.teardown === null && + e.nodes_start === null && + e.first === e.last && // either `null`, or a singular child + (e.f & EFFECT_PRESERVED) === 0 + ) { + e = e.first; + if ((type & BLOCK_EFFECT) !== 0 && (type & EFFECT_TRANSPARENT) !== 0 && e !== null) { + e.f |= EFFECT_TRANSPARENT; } + } - if (e !== null) { - e.parent = parent; + if (e !== null) { + e.parent = parent; - if (parent !== null) { - push_effect(e, parent); - } + if (parent !== null) { + push_effect(e, parent); + } - // if we're in a derived, add the effect there too - if ( - active_reaction !== null && - (active_reaction.f & DERIVED) !== 0 && - (type & ROOT_EFFECT) === 0 - ) { - var derived = /** @type {Derived} */ (active_reaction); - (derived.effects ??= []).push(e); - } + // if we're in a derived, add the effect there too + if ( + active_reaction !== null && + (active_reaction.f & DERIVED) !== 0 && + (type & ROOT_EFFECT) === 0 + ) { + var derived = /** @type {Derived} */ (active_reaction); + (derived.effects ??= []).push(e); } - } else { - console.trace('not pushing'); } return effect; @@ -388,13 +383,11 @@ export function block(fn, flags = 0) { return effect; } -// TODO i think we don't need `push` any more? /** * @param {(() => void)} fn - * @param {boolean} [push] */ -export function branch(fn, push = true) { - return create_effect(BRANCH_EFFECT | EFFECT_PRESERVED, fn, true, push); +export function branch(fn) { + return create_effect(BRANCH_EFFECT | EFFECT_PRESERVED, fn, true); } /** From f8b81521f11863d388f02d963a889113b3e80b96 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 13 Nov 2025 10:02:10 -0500 Subject: [PATCH 12/28] fix --- packages/svelte/src/internal/client/dom/blocks/each.js | 1 - 1 file changed, 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 810c3158ac39..70373a90c7fb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -213,7 +213,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f anchor = /** @type {Comment} */ (hydrate_node); mismatch = true; set_hydrating(false); - break; } var value = array[i]; From a1beaf3562a98097cf0111261831356427eb6d1d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 13 Nov 2025 10:56:52 -0500 Subject: [PATCH 13/28] note to self --- 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 70373a90c7fb..44c458d34e33 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -286,8 +286,11 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } if (!first_run) { - if (should_defer_append()) { + if (defer) { batch.oncommit(commit); + batch.ondiscard(() => { + // TODO presumably we need to do something here? + }); } else { commit(); } From 9cdc0e2f8183e4a26b786761f5c7979d47e7dcd7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 10:44:49 -0500 Subject: [PATCH 14/28] tweak/fix --- packages/svelte/src/internal/client/dom/blocks/each.js | 3 ++- 1 file changed, 2 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 44c458d34e33..115f878691c7 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -201,6 +201,7 @@ 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) { if ( @@ -229,7 +230,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f batch.skipped_effects.delete(item.e); } else { item = create_item( - first_run ? (hydrating ? hydrate_node : anchor) : null, + first_run ? anchor : null, prev, value, key, From bee2e980c854b77c8db67555013e14ec32cfa178 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 10:57:39 -0500 Subject: [PATCH 15/28] tidy up --- packages/svelte/src/internal/client/dom/blocks/each.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 115f878691c7..89d3cdf25dab 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -154,7 +154,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var first_run = true; function commit() { - reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection); + reconcile(each_effect, array, state, anchor, flags, get_key); if (fallback !== null) { if (array.length === 0) { @@ -162,7 +162,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f anchor.before(fallback.fragment); fallback.fragment = null; } else { - // TODO if this was resume_effect(fallback.effect); } @@ -325,21 +324,18 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f * @param {Array} array * @param {EachState} state * @param {Element | Comment | Text} anchor - * @param {(anchor: Node, item: MaybeSource, index: number | Source, collection: () => V[]) => void} render_fn * @param {number} flags * @param {(value: V, index: number) => any} get_key - * @param {() => V[]} get_collection * @returns {void} */ -function reconcile(each_effect, array, state, anchor, render_fn, flags, get_key, get_collection) { +function reconcile(each_effect, array, state, anchor, flags, get_key) { var is_animated = (flags & EACH_IS_ANIMATED) !== 0; var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; var length = array.length; var onscreen = state.onscreen; var offscreen = state.offscreen; - var first = state.first; - var current = first; + var current = state.first; /** @type {undefined | Set} */ var seen; From 9bbdb754f37fb2960135657dfb073f02b1c9c22f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 10:58:51 -0500 Subject: [PATCH 16/28] simplify --- packages/svelte/src/internal/client/dom/blocks/each.js | 5 ----- 1 file changed, 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 89d3cdf25dab..2f758b5dd7f5 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -330,7 +330,6 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f */ function reconcile(each_effect, array, state, anchor, flags, get_key) { var is_animated = (flags & EACH_IS_ANIMATED) !== 0; - var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; var length = array.length; var onscreen = state.onscreen; @@ -409,10 +408,6 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { continue; } - if (should_update) { - update_item(item, value, i, flags); - } - if ((item.e.f & INERT) !== 0) { resume_effect(item.e); if (is_animated) { From e9f52380a994963ae109f54530fa1518385fba48 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 11:01:38 -0500 Subject: [PATCH 17/28] unused --- packages/svelte/src/internal/client/dom/blocks/each.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 2f758b5dd7f5..52f477327cb6 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -383,11 +383,7 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { item = onscreen.get(key); if (item === undefined) { - item = offscreen.get(key); - - if (item === undefined) { - throw new Error('this should be impossible'); - } + item = /** @type {EachItem} */ (offscreen.get(key)); offscreen.delete(key); From 374786d3fc6fa40327a1f3ca3d6cb272a555acb2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 15:16:10 -0500 Subject: [PATCH 18/28] clarity --- .../src/internal/client/dom/blocks/each.js | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 52f477327cb6..5637f61bf2b9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -67,34 +67,34 @@ export function index(_, i) { * Pause multiple effects simultaneously, and coordinate their * subsequent destruction. Used in each blocks * @param {EachState} state - * @param {EachItem[]} items + * @param {EachItem[]} to_destroy * @param {null | Node} controlled_anchor */ -function pause_effects(state, items, controlled_anchor) { +function pause_effects(state, to_destroy, controlled_anchor) { /** @type {TransitionManager[]} */ var transitions = []; - var length = items.length; + var length = to_destroy.length; for (var i = 0; i < length; i++) { - pause_children(items[i].e, transitions, true); - } - - var is_controlled = length > 0 && transitions.length === 0 && controlled_anchor !== null; - - // If we have a controlled anchor, it means that the each block is inside a single - // DOM element, so we can apply a fast-path for clearing the contents of the element. - if (is_controlled) { - var parent_node = /** @type {Element} */ ( - /** @type {Element} */ (controlled_anchor).parentNode - ); - clear_text_content(parent_node); - parent_node.append(/** @type {Element} */ (controlled_anchor)); - state.onscreen.clear(); + pause_children(to_destroy[i].e, transitions, true); } run_out_transitions(transitions, () => { + var is_controlled = length > 0 && transitions.length === 0 && controlled_anchor !== null; + + // If we have a controlled anchor, it means that the each block is inside a single + // DOM element, so we can apply a fast-path for clearing the contents of the element. + if (is_controlled) { + var parent_node = /** @type {Element} */ ( + /** @type {Element} */ (controlled_anchor).parentNode + ); + clear_text_content(parent_node); + parent_node.append(/** @type {Element} */ (controlled_anchor)); + state.onscreen.clear(); + } + for (var i = 0; i < length; i++) { - var item = items[i]; + var item = to_destroy[i]; if (!is_controlled) { state.onscreen.delete(item.k); } @@ -103,7 +103,7 @@ function pause_effects(state, items, controlled_anchor) { } }); - link(state, items[0].prev, items[length - 1].next); + link(state, to_destroy[0].prev, to_destroy[length - 1].next); } /** From 1d7600092825468eda7c143f9dfc0d0b91710156 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 15:17:31 -0500 Subject: [PATCH 19/28] 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 5637f61bf2b9..32c5cd87293d 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -82,6 +82,9 @@ function pause_effects(state, to_destroy, controlled_anchor) { run_out_transitions(transitions, () => { var is_controlled = length > 0 && 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 we have a controlled anchor, it means that the each block is inside a single // DOM element, so we can apply a fast-path for clearing the contents of the element. if (is_controlled) { From ae12901b05c86b538bdecb06a08e6ef9cb0e39dd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 15:18:32 -0500 Subject: [PATCH 20/28] simplify --- packages/svelte/src/internal/client/dom/blocks/each.js | 8 +++----- 1 file changed, 3 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 32c5cd87293d..80128c83a3a0 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -370,12 +370,10 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); - item = onscreen.get(key); + item = /** @type {EachItem} */ (onscreen.get(key)); - if (item !== undefined) { - item.a?.measure(); - (to_animate ??= new Set()).add(item); - } + item.a?.measure(); + (to_animate ??= new Set()).add(item); } } From 27c2908becf4af60dd1a89a02592a1dffa42b722 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 16:22:00 -0500 Subject: [PATCH 21/28] one test to go --- .../src/internal/client/dom/blocks/each.js | 47 +++++++++---------- .../svelte/src/internal/client/types.d.ts | 10 ++-- 2 files changed, 26 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 80128c83a3a0..cc68b9203179 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -93,20 +93,24 @@ function pause_effects(state, to_destroy, controlled_anchor) { ); clear_text_content(parent_node); parent_node.append(/** @type {Element} */ (controlled_anchor)); - state.onscreen.clear(); + state.items.clear(); } for (var i = 0; i < length; i++) { var item = to_destroy[i]; if (!is_controlled) { - state.onscreen.delete(item.k); + state.items.delete(item.k); } destroy_effect(item.e, !is_controlled); } - }); - link(state, to_destroy[0].prev, to_destroy[length - 1].next); + link(state, to_destroy[0].prev, to_destroy[length - 1].next); + + if (state.first === to_destroy[0]) { + state.first = to_destroy[0].prev; + } + }); } /** @@ -123,7 +127,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var anchor = node; /** @type {EachState} */ - var state = { flags, onscreen: new Map(), offscreen: new Map(), first: null }; + var state = { flags, items: new Map(), first: null, last: null }; var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; @@ -221,7 +225,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var value = array[i]; var key = get_key(value, i); - var item = first_run ? null : state.onscreen.get(key) ?? state.offscreen.get(key); + var item = first_run ? null : state.items.get(key); if (item) { // update before reconciliation, to trigger any async updates @@ -243,6 +247,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f ); if (first_run) { + item.o = true; + if (prev === null) { state.first = item; } else { @@ -250,10 +256,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } prev = item; - state.onscreen.set(key, item); - } else { - state.offscreen.set(key, item); } + + state.items.set(key, item); } keys.add(key); @@ -282,7 +287,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f set_hydrate_node(skip_nodes()); } - for (const [key, item] of state.onscreen) { + for (const [key, item] of state.items) { if (!keys.has(key)) { batch.skipped_effects.add(item.e); } @@ -335,8 +340,7 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { var is_animated = (flags & EACH_IS_ANIMATED) !== 0; var length = array.length; - var onscreen = state.onscreen; - var offscreen = state.offscreen; + var items = state.items; var current = state.first; /** @type {undefined | Set} */ @@ -370,7 +374,7 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); - item = /** @type {EachItem} */ (onscreen.get(key)); + item = /** @type {EachItem} */ (items.get(key)); item.a?.measure(); (to_animate ??= new Set()).add(item); @@ -381,12 +385,12 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { value = array[i]; key = get_key(value, i); - item = onscreen.get(key); + item = /** @type {EachItem} */ (items.get(key)); - if (item === undefined) { - item = /** @type {EachItem} */ (offscreen.get(key)); + state.first ??= item; - offscreen.delete(key); + if (!item.o) { + item.o = true; var next = prev ? prev.next : current; @@ -396,8 +400,6 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { move(item, next, anchor); prev = item; - onscreen.set(key, prev); - matched = []; stashed = []; @@ -531,12 +533,6 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { // to the first offscreen item, etc prev.e.next = null; } - - for (var unused of offscreen.values()) { - destroy_effect(unused.e); - } - - offscreen.clear(); } /** @@ -596,6 +592,7 @@ function create_item(anchor, prev, value, key, index, render_fn, flags, get_coll a: null, // @ts-expect-error e: null, + o: false, prev, next: null }; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index fdd4741377ef..c4b3bdb3344a 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -64,14 +64,10 @@ export type TemplateNode = Text | Element | Comment; export type Dom = TemplateNode | TemplateNode[]; export type EachState = { - /** flags */ flags: number; - /** items that are currently onscreen */ - onscreen: Map; - /** items that are currently offscreen */ - offscreen: Map; - /** head of the linked list of items */ + items: Map; first: EachItem | null; + last: EachItem | null; }; export type EachItem = { @@ -85,6 +81,8 @@ export type EachItem = { i: number | Source; /** key */ k: unknown; + /** true if onscreen */ + o: boolean; prev: EachItem | null; next: EachItem | null; }; From 5236717531637afd931bb0209e1e9ee06e522e50 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 17:10:35 -0500 Subject: [PATCH 22/28] holy shit i think it works --- .../src/internal/client/dom/blocks/each.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index cc68b9203179..4fccca11bfc9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -80,33 +80,35 @@ function pause_effects(state, to_destroy, controlled_anchor) { } run_out_transitions(transitions, () => { - var is_controlled = length > 0 && transitions.length === 0 && controlled_anchor !== null; + // If we have a controlled anchor, it means that the each block is inside a single + // DOM element, so we can apply a fast-path for clearing the contents of the element. + 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 we have a controlled anchor, it means that the each block is inside a single - // DOM element, so we can apply a fast-path for clearing the contents of the element. - if (is_controlled) { + if (fast_path) { var parent_node = /** @type {Element} */ ( /** @type {Element} */ (controlled_anchor).parentNode ); clear_text_content(parent_node); parent_node.append(/** @type {Element} */ (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 (!is_controlled) { + + if (!fast_path) { state.items.delete(item.k); + link(state, item.prev, item.next); } - destroy_effect(item.e, !is_controlled); + destroy_effect(item.e, !fast_path); } - link(state, to_destroy[0].prev, to_destroy[length - 1].next); - if (state.first === to_destroy[0]) { state.first = to_destroy[0].prev; } From 002591d667886ebfe339b86ef54d71b1a0c4ffa8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 17:12:28 -0500 Subject: [PATCH 23/28] unused --- packages/svelte/src/internal/client/dom/blocks/each.js | 2 +- packages/svelte/src/internal/client/types.d.ts | 1 - 2 files changed, 1 insertion(+), 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 4fccca11bfc9..8d58f9f8b899 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -129,7 +129,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var anchor = node; /** @type {EachState} */ - var state = { flags, items: new Map(), first: null, last: null }; + var state = { flags, items: new Map(), first: null }; var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index c4b3bdb3344a..0ef78c334c8c 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -67,7 +67,6 @@ export type EachState = { flags: number; items: Map; first: EachItem | null; - last: EachItem | null; }; export type EachItem = { From 643fe6cc06ccef62b0f09c5094c49d0bf9ab2050 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 17:21:43 -0500 Subject: [PATCH 24/28] reduce number of bitwise checks --- .../src/internal/client/dom/blocks/each.js | 31 ++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 8d58f9f8b899..1a3ffdecd0b9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -132,6 +132,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var state = { flags, items: new Map(), 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; if (is_controlled) { var parent_node = /** @type {Element} */ (node); @@ -231,8 +233,14 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f if (item) { // update before reconciliation, to trigger any async updates - if ((flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0) { - update_item(item, value, i, flags); + if (is_reactive_value) { + internal_set(item.v, value); + } + + if (is_reactive_index) { + internal_set(/** @type {Value} */ (item.i), i); + } else { + item.i = i; } batch.skipped_effects.delete(item.e); @@ -537,25 +545,6 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { } } -/** - * @param {EachItem} item - * @param {any} value - * @param {number} index - * @param {number} type - * @returns {void} - */ -function update_item(item, value, index, type) { - if ((type & EACH_ITEM_REACTIVE) !== 0) { - internal_set(item.v, value); - } - - if ((type & EACH_INDEX_REACTIVE) !== 0) { - internal_set(/** @type {Value} */ (item.i), index); - } else { - item.i = index; - } -} - /** * @template V * @param {Node | null} anchor From 202217297e6b007edf24619d7c790644bd8179b6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 17:23:12 -0500 Subject: [PATCH 25/28] revert --- packages/svelte/src/internal/client/types.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 0ef78c334c8c..19645fcc329f 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -64,8 +64,11 @@ export type TemplateNode = Text | Element | Comment; export type Dom = TemplateNode | TemplateNode[]; export type EachState = { + /** flags */ flags: number; + /** a key -> item lookup */ items: Map; + /** head of the linked list of items */ first: EachItem | null; }; From f872934af04482730332594934f86d7ab3075a07 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 14 Nov 2025 17:25:07 -0500 Subject: [PATCH 26/28] small tweak --- packages/svelte/src/internal/client/dom/blocks/each.js | 8 ++++---- 1 file changed, 4 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 1a3ffdecd0b9..67cf46eff75c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -88,11 +88,11 @@ function pause_effects(state, to_destroy, controlled_anchor) { // just set `item.o` back to `false` if (fast_path) { - var parent_node = /** @type {Element} */ ( - /** @type {Element} */ (controlled_anchor).parentNode - ); + var anchor = /** @type {Element} */ (controlled_anchor); + var parent_node = /** @type {Element} */ (anchor.parentNode); + clear_text_content(parent_node); - parent_node.append(/** @type {Element} */ (controlled_anchor)); + parent_node.append(anchor); state.items.clear(); link(state, to_destroy[0].prev, to_destroy[length - 1].next); From 8f8de5030b7b0f7e11c92b1b2d7b324d9a05d02d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 15 Nov 2025 12:56:23 -0500 Subject: [PATCH 27/28] better comment --- packages/svelte/src/internal/client/dom/blocks/each.js | 5 +++-- 1 file changed, 3 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 67cf46eff75c..3cd20bc002ea 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -80,8 +80,9 @@ function pause_effects(state, to_destroy, controlled_anchor) { } run_out_transitions(transitions, () => { - // If we have a controlled anchor, it means that the each block is inside a single - // DOM element, so we can apply a fast-path for clearing the contents of the element. + // 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, From 4e45ffa6138c43ed8ff7f15f5902097cf4ca9651 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 15 Nov 2025 13:15:35 -0500 Subject: [PATCH 28/28] update note to self --- packages/svelte/src/internal/client/dom/blocks/each.js | 6 +++--- 1 file changed, 3 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 3cd20bc002ea..3f12593d0150 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -535,13 +535,13 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) { }); } + // TODO i have an inkling that the rest of this function is wrong... + // the offscreen items need to be linked, so that they all update correctly. + // the last onscreen item should link to the first offscreen item, etc each_effect.first = state.first && state.first.e; each_effect.last = prev && prev.e; if (prev) { - // TODO i think this is wrong... the offscreen items need to be linked, - // so that they all update correctly. the last onscreen item should link - // to the first offscreen item, etc prev.e.next = null; } }