diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ca5b68785c..dd230d3787 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -143,6 +143,21 @@ export class Batch { return (this.#stale_effects ??= new Map()); } + /** + * @type {Set | null} + */ + #stale_readers = null; + + /** + * Reactions that, while running in an earlier batch, read a value that this batch holds + * a newer version of, and that are therefore scheduled to re-run in this batch. Used to + * avoid scheduling the same reaction multiple times when it reads more than one such value. + * Lazily initialized for performance reasons. + */ + get stale_readers() { + return (this.#stale_readers ??= new Set()); + } + /** @type {Batch | null} */ prev = null; diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 8fffee8c47..62d26c0391 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -771,18 +771,27 @@ function get_batch_value(signal, first_time) { (active_effect.f & REACTION_IS_UPDATING) !== 0) || (active_effect !== null && (active_effect.f & ASYNC) !== 0); - if (reader !== null && reactive) { + if ( + reader !== null && + reactive && + // a reaction can read several stale values in one run — only schedule the re-run once + !batch.stale_readers.has(reader) + ) { + batch.stale_readers.add(reader); + if (current.is_eager) { // Reactions of eager effects with at least one non-eager effect (which we know is true here due to the stale_sources check above) // need to rerun because they could do something like `$state.eager(count) !== count`. if (reader.deps?.some((dep) => dep.f & EAGER_EFFECT)) { batch.oncommit(() => { + batch.stale_readers.delete(reader); Batch.ensure(); invalidate(reader, DIRTY); }); } } else { queue_micro_task(() => { + batch.stale_readers.delete(reader); const b = batch.activate(); invalidate(reader, MAYBE_DIRTY); b.flush(); 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}