From 3e92edfbf4d8a8b6a3e667604cc15bcf0eb102fd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 12:07:20 -0400 Subject: [PATCH] restore transferring discard callbacks in #merge If the surviving batch is discarded (e.g. after an unhandled error), the merged batch's discard callbacks must run too, or each blocks keep its pending state and leak items. --- .../src/internal/client/reactivity/batch.js | 4 ++ .../Item.svelte | 9 ++++ .../_config.js | 47 +++++++++++++++++++ .../main.svelte | 39 +++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/Item.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index f4c70c822f..2569acb897 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -662,6 +662,10 @@ export class Batch { this.oncommit(() => c(batch)); } + for (const c of batch.#discard_callbacks) { + this.ondiscard(() => c(batch)); + } + for (const [s, v] of batch.#skipped_branches) { this.#skipped_branches.set(s, v); this.unskipped_branches.delete(s); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/Item.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/Item.svelte new file mode 100644 index 0000000000..7e30dd0125 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/Item.svelte @@ -0,0 +1,9 @@ + + +{item} diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/_config.js new file mode 100644 index 0000000000..9bd1abce5c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/_config.js @@ -0,0 +1,47 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + const [a, push, clear, shift] = target.querySelectorAll('button'); + + shift.click(); + await tick(); + assert.deepEqual(logs, ['mount 1', 'mount 2', 'mount 3']); + + // batch X: a=1, pending + a.click(); + await tick(); + + // batch Y: list changes; the each block reads `a`, so Y merges into X. + // The each block registers commit/discard callbacks with Y + push.click(); + await tick(); + + // X resolves and throws while processing (not handled by any boundary), so it is discarded + // rather than committed. Y's discard callbacks must still run + let error = null; + shift.click(); + try { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + flushSync(); + } + } catch (e) { + error = e; + } + assert.match(/** @type {Error} */ (error)?.message, /^boom/); + + logs.length = 0; + clear.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + `

a: 0

` + ); + // the removed items must be destroyed, not kept around for the discarded batch Y + assert.deepEqual(logs, ['destroy 1', 'destroy 2', 'destroy 3']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/main.svelte new file mode 100644 index 0000000000..ea1bfd8594 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-discard-after-error/main.svelte @@ -0,0 +1,39 @@ + + + + + + + + + + {#if check(await push(a))} +

a: {a}

+ {/if} + + + {#each list.slice(0, a + 10) as item (item)} + + {/each} + + {#snippet pending()} +

loading...

+ {/snippet} +