From d822c50b758d3ce387af555f79f3a09403d24a2f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 8 Oct 2026 15:26:50 +0200 Subject: [PATCH] ensure offscreen each items always last; reschedule effects from earlier batches when unskipping --- .../src/internal/client/dom/blocks/each.js | 7 +++ .../src/internal/client/reactivity/batch.js | 24 +++++++---- .../async-each-item-skipped-stale/_config.js | 34 +++++++++++++++ .../async-each-item-skipped-stale/main.svelte | 35 +++++++++++++++ .../_config.js | 28 ++++++++++++ .../main.svelte | 23 ++++++++++ .../async-if-skipped-branch-stale/_config.js | 34 +++++++++++++++ .../async-if-skipped-branch-stale/main.svelte | 43 +++++++++++++++++++ 8 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/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 35b6430b60..877eb3ff84 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -166,6 +166,13 @@ function destroy_effects(state, to_destroy, remove_dom = true) { const fragment = document.createDocumentFragment(); move_effect(e, fragment); + + // `reconcile` expects offscreen items to come after all onscreen items + if (e !== state.effect.last) { + link(state, e.prev, e.next); + link(state, state.effect.last, e); + link(state, e, null); + } } else { destroy_effect(to_destroy[i], remove_dom); } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 281e6fa0ff..eca4a35c48 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -404,18 +404,24 @@ export class Batch { * @param {Effect} effect */ unskip_effect(effect) { - var tracked = this.#skipped_branches.get(effect); - if (tracked) { - this.#skipped_branches.delete(effect); - - for (var e of tracked.d) { - this.schedule(e, DIRTY); - } + for (var batch = /** @type {Batch | null} */ (this); batch !== null; batch = batch.prev) { + // Earlier (non-fork) batches that skip this branch have reset the effects inside it that are + // dirty because of their changes. This batch sees those changes as well, so it runs them, too + var tracked = + batch === this || !batch.is_fork ? batch.#skipped_branches.get(effect) : undefined; + + if (tracked) { + for (var e of tracked.d) { + this.schedule(e, DIRTY); + } - for (e of tracked.m) { - this.schedule(e, MAYBE_DIRTY); + for (e of tracked.m) { + this.schedule(e, MAYBE_DIRTY); + } } } + + this.#skipped_branches.delete(effect); this.unskipped_branches.add(effect); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/_config.js new file mode 100644 index 0000000000..6adae749b5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + + const [skip, readd, resolve_earlier, resolve_all] = target.querySelectorAll('button'); + const buttons = + ''; + + assert.htmlEqual(target.innerHTML, `${buttons}0|01|1`); + + // the second item is skipped by this batch, which also changes what it shows + skip.click(); + await tick(); + // the second item is needed again by a later batch + readd.click(); + await tick(); + + // resolve the earlier batch first, so that it commits first + resolve_earlier.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}3|6`); + + for (let i = 0; i < 5; i++) { + await tick(); + resolve_all.click(); + } + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}5|101|2`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/main.svelte new file mode 100644 index 0000000000..b5347b9832 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-item-skipped-stale/main.svelte @@ -0,0 +1,35 @@ + + + + + + + +{#each list as x}{x}|{await delay(x * c)}{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/_config.js new file mode 100644 index 0000000000..09f57c9166 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + + const [set_b, list_1, list_2, resolve_all] = target.querySelectorAll('button'); + const buttons = + ''; + + assert.htmlEqual(target.innerHTML, `${buttons}01`); + + set_b.click(); + await tick(); + list_1.click(); + await tick(); + list_2.click(); + + for (let i = 0; i < 5; i++) { + await tick(); + resolve_all.click(); + } + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}425`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/main.svelte new file mode 100644 index 0000000000..ec5153cd84 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-keyed-order-overlapping/main.svelte @@ -0,0 +1,23 @@ + + + + + + + +{#each list as x (x)}{await delay(x + b)}{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/_config.js new file mode 100644 index 0000000000..dad1f1b244 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + + const [skip, show, resolve_earlier, resolve_all] = target.querySelectorAll('button'); + const buttons = + ''; + + assert.htmlEqual(target.innerHTML, `${buttons}

p0

c0i0`); + + // the branch is skipped by this batch, which also changes what the branch shows + skip.click(); + await tick(); + // the branch is needed again by a later batch + show.click(); + await tick(); + + // resolve the earlier batch first, so that it commits first + resolve_earlier.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

p1

c0`); + + for (let i = 0; i < 5; i++) { + await tick(); + resolve_all.click(); + } + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

p1

c1i1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/main.svelte new file mode 100644 index 0000000000..c903b007f1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if-skipped-branch-stale/main.svelte @@ -0,0 +1,43 @@ + + + + + + + +

{await delay('p' + b)}

+{await delay('c' + c)} +{#if show}{await delay('i' + b)}{/if}