From d9acd2da90d1e0c23185c140cb29935937dd3ada Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 04:52:05 -0400 Subject: [PATCH 1/2] test: add async tests that already pass on main, unskip async-overlap-multiple-2 (#18981) These tests from #18880 already pass on `main`. Moving them out of that PR makes it clearer what behaviour is actually changing over there --- .../_config.js | 33 +++++++++++++ .../main.svelte | 26 ++++++++++ .../_config.js | 33 +++++++++++++ .../main.svelte | 23 +++++++++ .../async-derived-deferred-clean/_config.js | 29 ++++++++++++ .../async-derived-deferred-clean/main.svelte | 22 +++++++++ .../_config.js | 36 ++++++++++++++ .../main.svelte | 18 +++++++ .../async-derived-not-overfiring-2/_config.js | 38 +++++++++++++++ .../main.svelte | 21 +++++++++ .../async-eager-effect-loop/_config.js | 16 +++++++ .../async-eager-effect-loop/main.svelte | 14 ++++++ .../_config.js | 36 ++++++++++++++ .../main.svelte | 26 ++++++++++ .../Child.svelte | 10 ++++ .../_config.js | 47 +++++++++++++++++++ .../main.svelte | 31 ++++++++++++ .../async-overlap-multiple-2/_config.js | 1 - .../_config.js | 40 ++++++++++++++++ .../main.svelte | 22 +++++++++ .../async-source-only-overlap/_config.js | 30 ++++++++++++ .../async-source-only-overlap/main.svelte | 18 +++++++ .../_config.js | 31 ++++++++++++ .../main.svelte | 23 +++++++++ .../_config.js | 38 +++++++++++++++ .../main.svelte | 25 ++++++++++ .../async-write-equals-real-value/_config.js | 36 ++++++++++++++ .../async-write-equals-real-value/main.svelte | 25 ++++++++++ .../_config.js | 33 +++++++++++++ .../main.svelte | 25 ++++++++++ 30 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/_config.js new file mode 100644 index 0000000000..58eab0f674 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [x, show, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

true

1

${buttons}`); + assert.deepEqual(logs, []); + + // write x — the batch is pending on its async expression, and claims + // the `positive` derived (marked through x) + x.click(); + await tick(); + assert.deepEqual(logs, []); + + // an independent batch runs the effect, which reads the claimed derived + // through the pending batch's overlay (x = 1, so `positive` is true) + show.click(); + await tick(); + assert.deepEqual(logs, ['positive true']); + + // the pending batch settles and commits x = 2 — `positive` recomputes + // to the same value (true), so the effect should not re-run + shift.click(); + await tick(); + assert.deepEqual(logs, ['positive true']); + assert.htmlEqual(target.innerHTML, `

true

2

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/main.svelte new file mode 100644 index 0000000000..91214d6874 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived-equality/main.svelte @@ -0,0 +1,26 @@ + + +

{positive}

+

{await delay(x)}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/_config.js new file mode 100644 index 0000000000..4a6e2587d4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [revert, y, shift] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + '

0

' + ); + assert.deepEqual(logs, []); + + // write x and revert it within the same batch — the batch is pending + // (its async expression re-runs), with previous === current for `x` + revert.click(); + await tick(); + assert.deepEqual(logs, []); + + // an independent batch runs the effect, which now reads `x` through + // the pending batch's overlay (seeing the held-back value 0) + y.click(); + await tick(); + assert.deepEqual(logs, ['effect 0 1']); + + // the pending batch settles and commits x === 0, i.e. exactly the value + // the effect already saw — it should not re-run + shift.click(); + await tick(); + assert.deepEqual(logs, ['effect 0 1']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/main.svelte new file mode 100644 index 0000000000..2c95b4a2f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-unchanged/main.svelte @@ -0,0 +1,23 @@ + + +

{await delay(x)}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/_config.js new file mode 100644 index 0000000000..99462b3a7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/_config.js @@ -0,0 +1,29 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [update, resolve_a, resolve_b] = target.querySelectorAll('button'); + assert.deepEqual(logs, [0]); + + update.click(); + await tick(); + assert.deepEqual(logs, [0]); + + // The new if block evaluates parity while the text effect is still deferred. + resolve_b.click(); + await tick(); + assert.deepEqual(logs, [0, 2]); + + // The batch must forget its earlier deferred DIRTY entry for parity. + resolve_a.click(); + await tick(); + assert.deepEqual(logs, [0, 2]); + assert.htmlEqual( + target.innerHTML, + '

2:0

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/main.svelte new file mode 100644 index 0000000000..d410c0b0e8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-clean/main.svelte @@ -0,0 +1,22 @@ + + + + + + +

{await delay('a', count)}:{parity}

+{#if await delay('b', count)} + {#if parity}

odd

{/if} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/_config.js new file mode 100644 index 0000000000..f0d0d0e926 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/_config.js @@ -0,0 +1,36 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [a, b, resolve, read] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + assert.htmlEqual(p.innerHTML, '0 0 0'); + + // A waits for its async expression, so its updated input is not yet rendered. + a.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 0 0'); + + // B commits with A's old input. Its derived values must remain batch-local. + b.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 1 2'); + + // Outside reactivity, both deriveds should reflect the real inputs, without + // changing the DOM until A resolves. + read.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 1 2'); + + resolve.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '1 2 4'); + read.click(); + assert.deepEqual(logs, [ + { a: 1, b: 1, c: 2, doubled: 4 }, + { a: 1, b: 1, c: 2, doubled: 4 } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/main.svelte new file mode 100644 index 0000000000..2ed8ab3b31 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity-2/main.svelte @@ -0,0 +1,18 @@ + + +

{await delay(a)} {c} {doubled}

+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/_config.js new file mode 100644 index 0000000000..03b8c3ffc8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + + const [increment_a, increment_a_b, shift_second, shift] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + increment_a.click(); + await tick(); + assert.deepEqual(logs, ['a_d: 1']); + logs.length = 0; + + increment_a_b.click(); + await tick(); + assert.deepEqual(logs, ['a_d: 2', 'c: 1']); + logs.length = 0; + + shift_second.click(); // a_d of second batch + await tick(); + assert.deepEqual(logs, ['c: 3']); + logs.length = 0; + + shift.click(); // a_d of first batch - should not rerun second batch + await tick(); + assert.deepEqual(logs, ['c: 1']); + logs.length = 0; + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '3'); + assert.deepEqual(logs, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/main.svelte new file mode 100644 index 0000000000..f327702493 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring-2/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{c}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js new file mode 100644 index 0000000000..4dee4cc511 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, '

0

'); + + increment.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, '

2

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte new file mode 100644 index 0000000000..8ba1897206 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-effect-loop/main.svelte @@ -0,0 +1,14 @@ + + + + +

{await delay(count, $state.eager(count))}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/_config.js new file mode 100644 index 0000000000..b7177c7135 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/_config.js @@ -0,0 +1,36 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + // running more than once per bump + async test({ assert, target, logs }) { + await tick(); + const [count, eager, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

-1

${buttons}`); + assert.deepEqual(logs, []); + + // count++ creates a pending batch; the inner block (containing the + // $state.eager expression) is created inside it and belongs to it. + // it does not depend on `count` + count.click(); + await tick(); + assert.deepEqual(logs, ['inner 0']); + + // an eager version bump re-runs the inner block in the eager batch's + // world — since the block doesn't depend on the pending batch's + // changes, it sees exactly the same values the owner's world would + eager.click(); + await tick(); + assert.deepEqual(logs, ['inner 0', 'inner 1']); + + // the pending batch settles — nothing the inner block sees has + // changed since its eager run, so it should not be re-run + shift.click(); + await tick(); + assert.deepEqual(logs, ['inner 0', 'inner 1']); + assert.htmlEqual(target.innerHTML, `

0

1${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/main.svelte new file mode 100644 index 0000000000..3e4bd83e93 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-reestablish-no-refetch/main.svelte @@ -0,0 +1,26 @@ + + +

{await delay(count)}

+{#if count >= 0} + {#if log($state.eager(eag))} + {eag} + {/if} +{/if} + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte new file mode 100644 index 0000000000..15ca4b6127 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/Child.svelte @@ -0,0 +1,10 @@ + + +{result} diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js new file mode 100644 index 0000000000..9ed25aba6a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/_config.js @@ -0,0 +1,47 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [start_a, start_b, resolve_a, resolve_b, reset] = target.querySelectorAll('button'); + + for (const boundary_first of [false, true]) { + logs.length = 0; + start_a.click(); + await tick(); + start_b.click(); + await tick(); + + // B's pending boundary must transfer effects to A regardless of whether + // it resolves before or after A renders. + if (boundary_first) { + resolve_b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + assert.deepEqual(logs, []); + resolve_a.click(); + } else { + resolve_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1

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

1

ready`); + assert.deepEqual(logs, ['ready']); + + reset.click(); + await tick(); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte new file mode 100644 index 0000000000..b38500ee63 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-pending-boundary-effects/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + +

{await delay_a(x)}

+{#if show && x > 0} + + {#snippet pending()}pending{/snippet} + + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js index f4aff83aad..0f422d1797 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-2/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target }) { await tick(); const [a_b, a_c, b_d, shift, pop] = target.querySelectorAll('button'); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/_config.js new file mode 100644 index 0000000000..3f32f32027 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/_config.js @@ -0,0 +1,40 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +// A reaction that reads its existing dependencies in a different order than last time +// is not a "new reader" of those dependencies — it must keep seeing its own batch's view, +// not the latest (uncommitted) value of a later pending batch +export default test({ + async test({ assert, target }) { + const [flip, a, , resolve] = target.querySelectorAll('button'); + + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

a0b0

true

a0

`); + + flip.click(); // B1: cond = false, slow(cond) pending + await tick(); + a.click(); // B2: a = 'a1', slow(a) pending + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

a0b0

true

a0

`); + + // B1 commits while B2 is still pending. The flipped branch reads `b` then `a` (reordered + // deps) — `a` must still resolve to B1's view (a0), not B2's uncommitted a1 + resolve.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

b0a0

false

a0

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

b0a1

false

a1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/main.svelte new file mode 100644 index 0000000000..c7ec3ccd52 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reordered-deps-not-new-reader/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + + {#snippet pending()}loading{/snippet} +

{cond ? a + b : b + a}

+

{await slow(cond)}

+

{await slow(a)}

+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js new file mode 100644 index 0000000000..a65c8a6fba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a_b, b, resolve] = target.querySelectorAll('button'); + + a_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte new file mode 100644 index 0000000000..1e107c0374 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-source-only-overlap/main.svelte @@ -0,0 +1,18 @@ + + + + + +{await push(a)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/_config.js b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/_config.js new file mode 100644 index 0000000000..d7f844881b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/_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, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

0

0

${buttons}`); + + // batch A writes `a` and pends on its async expression + a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

0

${buttons}`); + + // batch B writes `b` — it only shares the `{add(a, b)}` template expression + // (a leaf) with batch A, so the two are independent: B commits right away, + // rendering its own view of the world (in which `a` is still 0) + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

1

${buttons}`); + + // the async expression settles — A commits and brings the shared + // template expression up to date + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

1

2

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/main.svelte new file mode 100644 index 0000000000..a56fe9b0d8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-entangle/main.svelte @@ -0,0 +1,23 @@ + + +

{await delay(a)}

+

{c}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/_config.js b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/_config.js new file mode 100644 index 0000000000..7206812832 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/_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 [z, w, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

true

0

${buttons}`); + assert.deepEqual(logs, ['eval true']); + + // with no other batch pending, the equality cut-off works: `c` + // recomputes to the same value, the template expression is not + // re-evaluated + z.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + + // write w — the batch is pending on its async expression + w.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + + // z++ recomputes `c` to the same value again — the template expression + // should still not be re-evaluated, even though another batch is pending + z.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + + // the pending batch settles — nothing `c` depends on changed + shift.click(); + await tick(); + assert.deepEqual(logs, ['eval true']); + assert.htmlEqual(target.innerHTML, `

true

1

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/main.svelte new file mode 100644 index 0000000000..9c54dd79d6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-template-expression-equality-cut/main.svelte @@ -0,0 +1,25 @@ + + +

{log(c)}

+

{await delay(w)}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/_config.js b/packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/_config.js new file mode 100644 index 0000000000..be2a3bcfd3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/_config.js @@ -0,0 +1,36 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [b, z, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

0

a

${buttons}`); + assert.deepEqual(logs, ['fetch a']); + + // write x = 'b' — the batch is pending on its async expression + b.click(); + await tick(); + assert.deepEqual(logs, ['fetch a', 'fetch b']); + + // an independent batch runs the effect, which writes x = 'b' — the + // real value is already 'b', so this is a no-op: the async expression + // must not re-run (no needless refetch), and the batch must not be + // entangled with the pending one (z commits immediately) + z.click(); + await tick(); + assert.deepEqual(logs, ['fetch a', 'fetch b']); + assert.htmlEqual(target.innerHTML, `

1

a

${buttons}`); + + // the pending batch settles + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.deepEqual(logs, ['fetch a', 'fetch b']); + assert.htmlEqual(target.innerHTML, `

1

b

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/main.svelte new file mode 100644 index 0000000000..22276aba5a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-write-equals-real-value/main.svelte @@ -0,0 +1,25 @@ + + +

{z}

+

{await delay(x)}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/_config.js b/packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/_config.js new file mode 100644 index 0000000000..b1b6c717af --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [b, reset, shift] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

0

a

${buttons}`); + assert.deepEqual(logs, ['fetch a']); + + // write x = 'b' — the batch is pending on its async expression + b.click(); + await tick(); + assert.deepEqual(logs, ['fetch a', 'fetch b']); + + // an independent batch runs the effect, which resets x = 'a'. The real + // (pending) value is 'b', so this is a genuine change and must not be + // swallowed just because the effect's world still shows 'a' + reset.click(); + await tick(); + assert.deepEqual(logs, ['fetch a', 'fetch b', 'fetch a']); + + // resolve all in-flight runs — the reset must win + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

1

a

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/main.svelte new file mode 100644 index 0000000000..fe70fd762b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-write-equals-visible-value/main.svelte @@ -0,0 +1,25 @@ + + +

{z}

+

{await delay(x)}

+ + + From d69a3c3c1c4d81044f85cf4b4d46e0e41ca1558d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 10:20:15 -0400 Subject: [PATCH 2/2] 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); }