From d69a3c3c1c4d81044f85cf4b4d46e0e41ca1558d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 10:20:15 -0400 Subject: [PATCH 1/7] Update packages/svelte/src/internal/client/reactivity/batch.js --- packages/svelte/src/internal/client/reactivity/batch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 21ec9c833f..9d2a861148 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -998,7 +998,7 @@ export class Batch { wv_values.set(source, current.wv); } - for (const [effect, wv] of this.stale_effects) { + for (const [effect, wv] of this.#stale_effects ?? []) { wv_values.set(effect, wv); } From 90dc9fc329f61404dc3b5307d0e7b50b00c051bf Mon Sep 17 00:00:00 2001 From: Nic <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:58:41 -0400 Subject: [PATCH 2/7] test: cover branch commit order and merged blocking pending --- .../async-branch-commit-order/_config.js | 31 +++++++++++++++ .../async-branch-commit-order/main.svelte | 30 +++++++++++++++ .../async-merge-blocking-pending/_config.js | 38 +++++++++++++++++++ .../async-merge-blocking-pending/main.svelte | 38 +++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js new file mode 100644 index 0000000000..a006f88c29 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js @@ -0,0 +1,31 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, b, resolve_a1, resolve_b1] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

0

0

${buttons}`); + + // batch A waits on its key; batch B registers its key right away + a.click(); + await tick(); + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

0

${buttons}`); + + // the older batch commits first + resolve_a1.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

1

${buttons}`); + + // the newer batch commits with its own branch + resolve_b1.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

1

B

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte new file mode 100644 index 0000000000..c232f535a9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte @@ -0,0 +1,30 @@ + + +

{await delay(`b${b}`, b)}

+ +{#key k} +

{k}

+{/key} + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js new file mode 100644 index 0000000000..be95f3a159 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [X, Y, Z, W, resolve_e, resolve_x1] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

n = 0

x = 0

${buttons}`); + + // X is deferred on its own await + X.click(); + await tick(); + // Y is deferred on the await inside the new branch + Y.click(); + await tick(); + // Z hides that branch again, merges into Y, which merges into X + Z.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

n = 0

x = 0

${buttons}`); + assert.deepEqual(logs, ['delay x0', 'delay x1', 'delay e']); + + // the branch's await resolves while it is hidden + resolve_e.click(); + await tick(); + // W shows the branch again and merges into X + W.click(); + await tick(); + + // nothing is pending any more, so X commits + resolve_x1.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

n = 4

x = 1

cond: e

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte new file mode 100644 index 0000000000..d4d03aa6eb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte @@ -0,0 +1,38 @@ + + + +

n = {n}

+

x = {await delay(`x${x}`, x)}

+ {#if cond} +

cond: {await delay('e', 'e')}

+ {/if} + + {#snippet pending()} +

loading

+ {/snippet} +
+ + + + + + + From 5d7eef91f06e06155f5d137411a394225fc1de78 Mon Sep 17 00:00:00 2001 From: Nic <162764842+Nic-Polumeyv@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:09:23 -0400 Subject: [PATCH 3/7] simplify the two tests --- .../async-branch-commit-order/_config.js | 12 +++------ .../async-branch-commit-order/main.svelte | 18 +++++-------- .../async-merge-blocking-pending/_config.js | 22 +++++++--------- .../async-merge-blocking-pending/main.svelte | 25 ++++++------------- 4 files changed, 27 insertions(+), 50 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js index a006f88c29..11c3bce995 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js @@ -1,30 +1,26 @@ import { tick } from 'svelte'; import { test } from '../../test'; -const buttons = - ''; +const buttons = ''; export default test({ async test({ assert, target }) { await tick(); - const [a, b, resolve_a1, resolve_b1] = target.querySelectorAll('button'); - - assert.htmlEqual(target.innerHTML, `

0

0

${buttons}`); + const [a, b, shift] = target.querySelectorAll('button'); // batch A waits on its key; batch B registers its key right away a.click(); await tick(); b.click(); await tick(); - assert.htmlEqual(target.innerHTML, `

0

0

${buttons}`); // the older batch commits first - resolve_a1.click(); + shift.click(); await tick(); assert.htmlEqual(target.innerHTML, `

0

1

${buttons}`); // the newer batch commits with its own branch - resolve_b1.click(); + shift.click(); await tick(); assert.htmlEqual(target.innerHTML, `

1

B

${buttons}`); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte index c232f535a9..d0b1a57053 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte @@ -3,22 +3,17 @@ let b = $state(0); let pend = false; - const pending = new Map(); + const deferred = []; - function delay(key, value) { + function delay(value) { if (!pend) return value; - return new Promise((resolve) => pending.set(key, () => resolve(value))); + return new Promise((resolve) => deferred.push(() => resolve(value))); } - function resolve(key) { - pending.get(key)(); - pending.delete(key); - } - - const k = $derived(b === 0 ? await delay(`a${a}`, a) : 'B'); + const k = $derived(b === 0 ? await delay(a) : 'B'); -

{await delay(`b${b}`, b)}

+

{await delay(b)}

{#key k}

{k}

@@ -26,5 +21,4 @@ - - + diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js index be95f3a159..9cd8eba0b3 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js @@ -1,37 +1,33 @@ import { tick } from 'svelte'; import { test } from '../../test'; -const buttons = - ''; +const buttons = ''; export default test({ - async test({ assert, target, logs }) { + async test({ assert, target }) { await tick(); - const [X, Y, Z, W, resolve_e, resolve_x1] = target.querySelectorAll('button'); - - assert.htmlEqual(target.innerHTML, `

n = 0

x = 0

${buttons}`); + const [x, toggle, pop] = target.querySelectorAll('button'); // X is deferred on its own await - X.click(); + x.click(); await tick(); // Y is deferred on the await inside the new branch - Y.click(); + toggle.click(); await tick(); // Z hides that branch again, merges into Y, which merges into X - Z.click(); + toggle.click(); await tick(); assert.htmlEqual(target.innerHTML, `

n = 0

x = 0

${buttons}`); - assert.deepEqual(logs, ['delay x0', 'delay x1', 'delay e']); // the branch's await resolves while it is hidden - resolve_e.click(); + pop.click(); await tick(); // W shows the branch again and merges into X - W.click(); + toggle.click(); await tick(); // nothing is pending any more, so X commits - resolve_x1.click(); + pop.click(); await tick(); assert.htmlEqual(target.innerHTML, `

n = 4

x = 1

cond: e

${buttons}`); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte index d4d03aa6eb..d4cbd8c5b6 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte @@ -4,25 +4,19 @@ let cond = $state(false); let pend = false; - const pending = new Map(); + const deferred = []; - function delay(key, value) { - console.log(`delay ${key}`); + function delay(value) { if (!pend) return value; - return new Promise((resolve) => pending.set(key, () => resolve(value))); - } - - function resolve(key) { - pending.get(key)(); - pending.delete(key); + return new Promise((resolve) => deferred.push(() => resolve(value))); }

n = {n}

-

x = {await delay(`x${x}`, x)}

+

x = {await delay(x)}

{#if cond} -

cond: {await delay('e', 'e')}

+

cond: {await delay('e')}

{/if} {#snippet pending()} @@ -30,9 +24,6 @@ {/snippet}
- - - - - - + + + From 4d2ad8a189ac89c4bede7ed6c3d6aec6065701f4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 11:35:22 -0400 Subject: [PATCH 4/7] chore: add async-merge-blocking-pending test from #18983 (#18989) #18983 adds two new tests. One of them passes on `main`, so we can merge it independently. The other only passes on `pt-1`, so I'll add it to that branch --- .../async-merge-blocking-pending/_config.js | 34 +++++++++++++++++++ .../async-merge-blocking-pending/main.svelte | 29 ++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js new file mode 100644 index 0000000000..9cd8eba0b3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + async test({ assert, target }) { + await tick(); + const [x, toggle, pop] = target.querySelectorAll('button'); + + // X is deferred on its own await + x.click(); + await tick(); + // Y is deferred on the await inside the new branch + toggle.click(); + await tick(); + // Z hides that branch again, merges into Y, which merges into X + toggle.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

n = 0

x = 0

${buttons}`); + + // the branch's await resolves while it is hidden + pop.click(); + await tick(); + // W shows the branch again and merges into X + toggle.click(); + await tick(); + + // nothing is pending any more, so X commits + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

n = 4

x = 1

cond: e

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte new file mode 100644 index 0000000000..d4cbd8c5b6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-blocking-pending/main.svelte @@ -0,0 +1,29 @@ + + + +

n = {n}

+

x = {await delay(x)}

+ {#if cond} +

cond: {await delay('e')}

+ {/if} + + {#snippet pending()} +

loading

+ {/snippet} +
+ + + + From 86eb09b76ef9c81e8b34adf7eaaf63974a66aaa9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 13:40:24 -0400 Subject: [PATCH 5/7] chore: even more async tests (#18990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extracting some tests from #18983 — these pass on `main`, `pt-1`, `pt-2` and `pt-3`. The others in that PR don't pass on `main`, so will be dealt with separately --- .../_config.js | 33 ++++++++++++++++ .../main.svelte | 25 ++++++++++++ .../_config.js | 33 ++++++++++++++++ .../main.svelte | 32 +++++++++++++++ .../async-fork-commit-new-reader/_config.js | 35 +++++++++++++++++ .../async-fork-commit-new-reader/main.svelte | 25 ++++++++++++ .../async-stale-read-many-sources/_config.js | 39 +++++++++++++++++++ .../async-stale-read-many-sources/main.svelte | 23 +++++++++++ 8 files changed, 245 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/_config.js new file mode 100644 index 0000000000..d319831f2e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [x, s, resolve_a, resolve_b] = target.querySelectorAll('button'); + assert.htmlEqual(target.innerHTML, `${buttons}

0:5

5

`); + + // batch A: `t` becomes truthy (pending) + x.click(); + await tick(); + + // batch B: `d` becomes 0 (pending) + s.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0:5

5

`); + + // In A's world, `d` is recomputed to 0. That's the real value, but not the value + // A sees for it (the one from before B's change), so A has to record it + resolve_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1:0

5

`); + + resolve_b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1:0

0

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/main.svelte new file mode 100644 index 0000000000..9fe34a5e12 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-equal-real-value-hidden/main.svelte @@ -0,0 +1,25 @@ + + + + + + + +{#if d >= 0} +

{t}:{d}

+{/if} + +

{await delay('b', s, 5)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/_config.js new file mode 100644 index 0000000000..fb0b6e8a4f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +// While batch A (x) commits, a pre effect writes to `s`. That marks `label` (via `big`) as +// maybe dirty, but it doesn't actually change. `label` must still show the value from A's +// point of view, not the one from the later, still-pending batch B (t). +export default test({ + async test({ assert, target }) { + await tick(); + const [x, t, shift] = target.querySelectorAll('button'); + + // A + x.click(); + await tick(); + + // B + t.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

0

0 false-0

`); + + // A commits, B is still pending + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

0

1 false-0

`); + + // B commits + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

1

1 false-1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/main.svelte new file mode 100644 index 0000000000..1791827eae --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-write-keeps-batch-derived/main.svelte @@ -0,0 +1,32 @@ + + + + + + +

{await push(x)}

+

{await push(t)}

+ +{#key label}{/key} + +

{x} {label}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/_config.js new file mode 100644 index 0000000000..a9e860bec7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/_config.js @@ -0,0 +1,35 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +// The template effect is deferred by the fork (it depends on `s` via `big`), but before the +// fork is committed, it re-runs in the real world and starts reading `s` directly. +// Committing the fork (while another batch is pending) must still update it. +export default test({ + async test({ assert, target }) { + await tick(); + const [u, f, cond, commit, shift] = target.querySelectorAll('button'); + + // unrelated pending batch + u.click(); + await tick(); + + f.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

false

`); + + cond.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

0

`); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

1

`); + + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/main.svelte new file mode 100644 index 0000000000..a52d6b4dfd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-new-reader/main.svelte @@ -0,0 +1,25 @@ + + + + + + + + +

{await push(u)}

+

{cond ? s : big}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/_config.js new file mode 100644 index 0000000000..f5318b930a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/_config.js @@ -0,0 +1,39 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; +/** @param {number} offset */ +const list = (offset) => Array.from({ length: 2000 }, (_, i) => `${i + offset},`).join(''); + +// When the branch is created in B1, the each block reads all 2000 items, each of which B2 holds +// a newer value of. The each block must be scheduled to re-run in B2 once, not once per stale +// value — otherwise B2 is flushed 2000 times (each flush being O(size of B2)), which takes seconds +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [show, items, shift] = target.querySelectorAll('button'); + + // B1: show = true -> pending + show.click(); + await tick(); + + // B2: increment all items -> pending + items.click(); + await tick(); + + // B1 resolves -> the branch is created, reading 2000 values that are stale relative to B2 + const start = performance.now(); + shift.click(); + await tick(); + const duration = performance.now() - start; + + assert.ok(duration < 1000, `took ${duration}ms`); + assert.htmlEqual(target.innerHTML, `${buttons}

0

${list(0)}
`); + + // B2 resolves + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

${list(1)}
`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/main.svelte new file mode 100644 index 0000000000..f932241cc6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-read-many-sources/main.svelte @@ -0,0 +1,23 @@ + + + + + + +

{await delay(items[0])}

+ +{#if await delay(show)} +
+ {#each items as item}{item},{/each} +
+{/if} From cea4875a2fe9a7a99c83daa690f26d2191729760 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 13:54:10 -0400 Subject: [PATCH 6/7] add tests from #18983 that fail on main but pass here --- .../_config.js | 39 +++++++++++++++ .../main.svelte | 42 +++++++++++++++++ .../_config.js | 33 +++++++++++++ .../main.svelte | 38 +++++++++++++++ .../async-eager-batch-no-merge/_config.js | 44 +++++++++++++++++ .../async-eager-batch-no-merge/main.svelte | 20 ++++++++ .../async-effect-resume-no-rerun/Child.svelte | 9 ++++ .../async-effect-resume-no-rerun/_config.js | 37 +++++++++++++++ .../async-effect-resume-no-rerun/main.svelte | 29 ++++++++++++ .../Item.svelte | 9 ++++ .../_config.js | 47 +++++++++++++++++++ .../main.svelte | 39 +++++++++++++++ 12 files changed, 386 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte 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/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/_config.js new file mode 100644 index 0000000000..ae9ef996f6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/_config.js @@ -0,0 +1,39 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [s, u, d, resolve_a, resolve_b, resolve_c] = target.querySelectorAll('button'); + + // batch A: `d` becomes 10, but is not evaluated yet (the `` is deferred) + s.click(); + await tick(); + + // batch B + u.click(); + await tick(); + + // batch C overrides `d` while A and B are pending. This must not mark `d` as + // clean, because the other batches haven't seen its new value (10) yet + d.click(); + await tick(); + + // B sees A's change (but not C's), so `d` is 10 in its world + resolve_b.click(); + await tick(); + assert.equal(logs[0], 'probe 10'); + + resolve_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

0

1010`); + + resolve_c.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

1

9999`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/main.svelte new file mode 100644 index 0000000000..08c00a4911 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-assign-keeps-maybe-dirty/main.svelte @@ -0,0 +1,42 @@ + + + + + + + + + +

{await delay('a', s)}

+

{await delay('c', w)}

+ +{#if show} + {d} +{/if} + +{#if await delay('b', u)} + {await probe(d)} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js new file mode 100644 index 0000000000..8f021032e0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Batch B (x) commits while an earlier batch A (y) is still pending, but B stays around because +// of the async work in the new boundary. While B flushes its effects, an effect creates a new batch C, +// and `d` is computed for the first time afterwards (so it's captured in both B and C). When A +// commits later, it sees B's previous values, which must be consistent (`d` must be `x * 2`). +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [y, x, shift] = target.querySelectorAll('button'); + assert.deepEqual(logs, [0, 0, 0]); + + // A + y.click(); + await tick(); + + // B + x.click(); + await tick(); + assert.deepEqual(logs, [0, 0, 0, 0, 1, 2]); + + // A commits + shift.click(); + await tick(); + + for (let i = 0; i < logs.length; i += 3) { + assert.equal(logs[i + 2], logs[i + 1] * 2, `torn read: ${logs.slice(i, i + 3)}`); + } + + assert.deepEqual(logs.slice(-3), [1, 1, 2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte new file mode 100644 index 0000000000..5cd0876884 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte @@ -0,0 +1,38 @@ + + + + + + +

{y ? await push(y) : y}

+ +{#if x} + +

{await push(x)}

+ {#snippet pending()} +

loading

+ {/snippet} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/_config.js new file mode 100644 index 0000000000..f90497846c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/_config.js @@ -0,0 +1,44 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +// The eager batch that re-runs `load` for the new `$state.eager(filter)` value computes it +// with the old `page`, because eager batches don't see other batches' changes. Therefore it +// must not be merged into the (pending) `page` batch — else its result would resolve that +// batch's `load(2, ...)` and be shown next to `page: 2` +export default test({ + async test({ assert, target }) { + await tick(); + const [page, filter, shift, pop] = target.querySelectorAll('button'); + + // B1: page = 2 -> load(2, 'a') pending + page.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

page: 1

result: 1/a

`); + + // filter = 'b' -> eager batch re-runs load(1, 'b'), pending + filter.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

page: 1

result: 1/a

`); + + // resolve load(1, 'b') -> the eager batch commits on its own + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

page: 1

result: 1/b

`); + + // resolve load(2, 'a') -> B1 commits (showing B1's own result is fine, as is + // waiting for load(2, 'b'), as long as the result matches the page) + shift.click(); + await tick(); + const [p1, p2] = target.querySelectorAll('p'); + assert.equal(p1.textContent, 'page: 2'); + assert.match(p2.textContent ?? '', /^result: 2\//); + + // load(2, 'b') resolves + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

page: 2

result: 2/b

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/main.svelte new file mode 100644 index 0000000000..008fabe2e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-batch-no-merge/main.svelte @@ -0,0 +1,20 @@ + + + + + + + +

page: {page}

+

result: {await load(page, $state.eager(filter))}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte new file mode 100644 index 0000000000..6f48eaf129 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte @@ -0,0 +1,9 @@ + + +

child

diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js new file mode 100644 index 0000000000..4fd60a98ce --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target, logs, raf }) { + await tick(); + const [x, s, toggle, shift] = target.querySelectorAll('button'); + assert.deepEqual(logs, ['effect false']); + + // keep an earlier batch pending, so that the next one commits while time travelling + x.click(); + await tick(); + + // `big` stays `false`, so the effect must not re-run + s.click(); + await tick(); + assert.deepEqual(logs, ['effect false']); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

1

child

' + ); + + // outro, then reintro before the outro finished: the branch is resumed + toggle.click(); + await tick(); + raf.tick(50); + toggle.click(); + await tick(); + raf.tick(200); + + assert.deepEqual(logs, ['effect false']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte new file mode 100644 index 0000000000..30d68f614b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte @@ -0,0 +1,29 @@ + + + + + + + +

{await push(x)}

+ +{#if show} +
+ +
+{/if} 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} +
From c66fab19b494005b5a92cf38d489d200f5793567 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 14:05:43 -0400 Subject: [PATCH 7/7] prettier --- packages/svelte/src/internal/client/runtime.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index a199284a94..ece71256c2 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -770,7 +770,9 @@ function get_batch_value(signal, first_time) { var reactive = in_derived || - (active_effect !== null && active_reaction === active_effect && (active_effect.f & REACTION_IS_UPDATING) !== 0) || + (active_effect !== null && + active_reaction === active_effect && + (active_effect.f & REACTION_IS_UPDATING) !== 0) || (active_effect !== null && (active_effect.f & ASYNC) !== 0); if (