From 0c61f1bb765683b8c01ced97e39900b85215114b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 12 Oct 2026 19:51:48 -0400 Subject: [PATCH 1/4] chore: merge #dirty_effects and #maybe_dirty_effects into #dirty_reactions (#18979) yoinking a minor change out of #18880 --- .../internal/client/dom/blocks/boundary.js | 11 ++-- .../src/internal/client/reactivity/batch.js | 59 ++++++++----------- .../src/internal/client/reactivity/utils.js | 11 ++-- 3 files changed, 34 insertions(+), 47 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 88e8caae56..ffcf2d4fd1 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -112,11 +112,8 @@ export class Boundary { #pending_count = 0; #pending_count_update_queued = false; - /** @type {Set} */ - #dirty_effects = new Set(); - - /** @type {Set} */ - #maybe_dirty_effects = new Set(); + /** @type {Map} */ + #dirty_reactions = new Map(); /** * A source containing the number of pending async deriveds/expressions. @@ -358,7 +355,7 @@ export class Boundary { // any effects that were previously deferred should be transferred // to the batch, which will flush in the next microtask - batch.transfer_effects(this.#dirty_effects, this.#maybe_dirty_effects); + batch.transfer_reactions(this.#dirty_reactions); } /** @@ -366,7 +363,7 @@ export class Boundary { * @param {Effect} effect */ defer_effect(effect) { - defer_effect(effect, this.#dirty_effects, this.#maybe_dirty_effects); + defer_effect(effect, this.#dirty_reactions); } /** diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 05f7586072..3ebeb519d0 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -177,16 +177,10 @@ export class Batch { #new_effects = []; /** - * Deferred effects (which run after async work has completed) that are DIRTY - * @type {Set} - */ - #dirty_effects = new Set(); - - /** - * Deferred effects that are MAYBE_DIRTY - * @type {Set} + * Deferred effects (which run after async work has completed) and their status (DIRTY or MAYBE_DIRTY) + * @type {Map} */ - #maybe_dirty_effects = new Set(); + #dirty_reactions = new Map(); /** * A map of branches that still exist, but will be destroyed when this batch @@ -341,14 +335,8 @@ export class Batch { // #is_deferred() is true, because traversing the tree could make // an if block that contains the last blocking pending effect falsy, // causing the block to no longer be deferred. - for (const e of this.#dirty_effects) { - this.#maybe_dirty_effects.delete(e); - set_signal_status(e, DIRTY); - this.schedule(e); - } - - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); + for (const [e, status] of this.#dirty_reactions) { + set_signal_status(e, status); this.schedule(e); } @@ -432,8 +420,7 @@ export class Batch { } // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + this.#dirty_reactions.clear(); // append/remove branches for (const fn of this.#commit_callbacks) fn(this); @@ -510,7 +497,7 @@ export class Batch { } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) { render_effects.push(effect); } else if (is_dirty(effect)) { - if ((flags & BLOCK_EFFECT) !== 0) this.#maybe_dirty_effects.add(effect); + if ((flags & BLOCK_EFFECT) !== 0) this.add_dirty_reaction(effect, MAYBE_DIRTY); update_effect(effect); } @@ -576,7 +563,7 @@ export class Batch { batch.async_deriveds.clear(); // Mark is not guaranteed not touch these, so we transfer them - this.transfer_effects(batch.#dirty_effects, batch.#maybe_dirty_effects); + this.transfer_reactions(batch.#dirty_reactions); /** * mark all effects that depend on `batch.current`, except the @@ -605,7 +592,7 @@ export class Batch { var effect = /** @type {Effect} */ (reaction); if (flags & (ASYNC | BLOCK_EFFECT) && !this.async_deriveds.has(effect)) { - this.#maybe_dirty_effects.delete(effect); + this.#dirty_reactions.delete(effect); set_signal_status(effect, DIRTY); this.schedule(effect); } @@ -629,7 +616,7 @@ export class Batch { */ #defer_effects(effects) { for (var i = 0; i < effects.length; i += 1) { - defer_effect(effects[i], this.#dirty_effects, this.#maybe_dirty_effects); + defer_effect(effects[i], this.#dirty_reactions); } } @@ -818,7 +805,7 @@ export class Batch { set_signal_status(effect, DIRTY); batch.schedule(effect); } else { - batch.#dirty_effects.add(effect); + batch.add_dirty_reaction(effect, DIRTY); } } } @@ -882,20 +869,24 @@ export class Batch { } /** - * @param {Set} dirty_effects - * @param {Set} maybe_dirty_effects + * @param {Map} dirty_reactions */ - transfer_effects(dirty_effects, maybe_dirty_effects) { - for (const e of dirty_effects) { - this.#dirty_effects.add(e); + transfer_reactions(dirty_reactions) { + for (const [effect, status] of dirty_reactions) { + this.add_dirty_reaction(effect, status); } - for (const e of maybe_dirty_effects) { - this.#maybe_dirty_effects.add(e); - } + dirty_reactions.clear(); + } - dirty_effects.clear(); - maybe_dirty_effects.clear(); + /** + * @param {Effect} effect + * @param {number} status + */ + add_dirty_reaction(effect, status) { + if (status === DIRTY || this.#dirty_reactions.get(effect) !== DIRTY) { + this.#dirty_reactions.set(effect, status); + } } /** @param {(batch: Batch) => void} fn */ diff --git a/packages/svelte/src/internal/client/reactivity/utils.js b/packages/svelte/src/internal/client/reactivity/utils.js index 3280f2372e..edd874b71c 100644 --- a/packages/svelte/src/internal/client/reactivity/utils.js +++ b/packages/svelte/src/internal/client/reactivity/utils.js @@ -4,14 +4,13 @@ import { set_signal_status } from './status.js'; /** * @param {Effect} effect - * @param {Set} dirty_effects - * @param {Set} maybe_dirty_effects + * @param {Map} dirty_reactions */ -export function defer_effect(effect, dirty_effects, maybe_dirty_effects) { +export function defer_effect(effect, dirty_reactions) { if ((effect.f & DIRTY) !== 0) { - dirty_effects.add(effect); - } else if ((effect.f & MAYBE_DIRTY) !== 0) { - maybe_dirty_effects.add(effect); + dirty_reactions.set(effect, DIRTY); + } else if ((effect.f & MAYBE_DIRTY) !== 0 && dirty_reactions.get(effect) !== DIRTY) { + dirty_reactions.set(effect, MAYBE_DIRTY); } // mark as clean so they get scheduled if they depend on pending async state From e22a67c432d18fa9647cdfd7b936fce4b7cb39e5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 12 Oct 2026 20:20:45 -0400 Subject: [PATCH 2/4] chore: tidy up comments and types in batch.js and sources.js (#18980) couple of trivial tidy-ups, ref #18880 --- packages/svelte/src/internal/client/reactivity/batch.js | 7 +++---- packages/svelte/src/internal/client/reactivity/sources.js | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 3ebeb519d0..f03880444f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -114,14 +114,14 @@ export class Batch { /** * The current values of any signals that are updated in this batch. * Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment) - * They keys of this map are identical to `this.#previous` + * The keys of this map are identical to `this.previous` * @type {Map} */ current = new Map(); /** * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place. - * They keys of this map are identical to `this.#current` + * The keys of this map are identical to `this.current` * @type {Map} */ previous = new Map(); @@ -202,7 +202,6 @@ export class Batch { #decrement_queued = false; constructor() { - // link batch if (last_batch === null) { first_batch = last_batch = this; } else { @@ -1453,7 +1452,7 @@ export function fork(fn) { }, discard: () => { // cause any MAYBE_DIRTY deriveds to update - // if they depend on things thath changed + // if they depend on things that changed // inside the discarded fork for (var source of batch.current.keys()) { source.wv = increment_write_version(); diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 4b10ddbd31..14d7b9a2e5 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -70,9 +70,9 @@ export function set_eager_effects_deferred() { */ // TODO rename this to `state` throughout the codebase export function source(v, stack) { - /** @type {Value} */ + /** @type {Source} */ var signal = { - f: 0, // TODO ideally we could skip this altogether, but it causes type errors + f: 0, v, reactions: null, equals, From 6480a9737f0ccc610cb8692b637c5f86ad141646 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 12 Oct 2026 20:38:31 -0400 Subject: [PATCH 3/4] chore: make Batch prev/next/started public (#18982) converts some private fields to public ones, because on #18880 they are used outside the class. breaking the change out removes some diff noise --- .../src/internal/client/reactivity/batch.js | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index f03880444f..655dfc6598 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -98,15 +98,15 @@ export class Batch { id = uid++; /** True as soon as `#process` was called */ - #started = false; + started = false; linked = true; /** @type {Batch | null} */ - #prev = null; + prev = null; /** @type {Batch | null} */ - #next = null; + next = null; /** @type {Map>>} */ async_deriveds = new Map(); @@ -205,8 +205,8 @@ export class Batch { if (last_batch === null) { first_batch = last_batch = this; } else { - last_batch.#next = this; - this.#prev = last_batch; + last_batch.next = this; + this.prev = last_batch; } last_batch = this; @@ -320,7 +320,7 @@ export class Batch { } #process() { - this.#started = true; + this.started = true; if (DEV) { // track all the values that were updated during this flush, @@ -522,7 +522,7 @@ export class Batch { } #find_earlier_batch() { - var batch = this.#prev; + var batch = this.prev; while (batch !== null) { if (!batch.is_fork) { @@ -534,7 +534,7 @@ export class Batch { } } - batch = batch.#prev; + batch = batch.prev; } return null; @@ -705,7 +705,7 @@ export class Batch { // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more // recent value for a given source - for (let batch = first_batch; batch !== null; batch = batch.#next) { + for (let batch = first_batch; batch !== null; batch = batch.next) { var is_earlier = batch.id < this.id; /** @type {Source[]} */ @@ -742,7 +742,7 @@ export class Batch { ); // If not started yet or no sources to update (which is e.g. possible for the very first batch) then bail - if (!batch.#started || current.length === 0) continue; + if (!batch.started || current.length === 0) continue; // Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds) var others = current.filter((source) => !this.current.has(source)); @@ -908,7 +908,7 @@ export class Batch { if (!is_processing && !is_flushing_sync) { queue_micro_task(() => { - if (!batch.#started) { + if (!batch.started) { batch.flush(); } }); @@ -919,7 +919,7 @@ export class Batch { } apply() { - if (!async_mode_flag || (!this.is_fork && this.#prev === null && this.#next === null)) { + if (!async_mode_flag || (!this.is_fork && this.prev === null && this.next === null)) { batch_values = null; return; } @@ -932,7 +932,7 @@ export class Batch { } // ...and undo changes belonging to other batches unless they intersect - for (let batch = first_batch; batch !== null; batch = batch.#next) { + for (let batch = first_batch; batch !== null; batch = batch.next) { if (batch === this || batch.is_fork) continue; // If two batches intersect, the latter batch will be merged into the earlier batch, @@ -993,19 +993,19 @@ export class Batch { // running it multiple times to not corrupt the linked list if (!this.linked) return; - var prev = this.#prev; - var next = this.#next; + var prev = this.prev; + var next = this.next; if (prev === null) { first_batch = next; } else { - prev.#next = next; + prev.next = next; } if (next === null) { last_batch = prev; } else { - next.#prev = prev; + next.prev = prev; } this.linked = false; From d9acd2da90d1e0c23185c140cb29935937dd3ada Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 13 Oct 2026 04:52:05 -0400 Subject: [PATCH 4/4] 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)}

+ + +