diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 92e87cc177..33ab2ac3e8 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -22,7 +22,7 @@ import { TEMPLATE_EXPRESSION } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; -import { deferred, define_property } from '../../shared/utils.js'; +import { deferred, define_property, has_own_property, includes } from '../../shared/utils.js'; import { active_reaction, get, @@ -168,6 +168,7 @@ export class Batch { * tuples. `owner` is the live batch whose pre-write value we are seeing, * or `null` if the value belongs to this batch. For forks this also stores * world-local derived values between activations. + * `null` if no values have been observed yet, or if this is the only batch that exists. * @type {Map | null} */ values = null; @@ -280,10 +281,11 @@ export class Batch { /** * Reactions that observed the pre-write world of this batch via its active - * overlay while it was pending. When this batch commits, they - * re-run with the real values. + * overlay while it was pending, mapped to the values they saw. When this + * batch commits, readers whose observed values differ from the committed + * ones re-run with the real values. * Lazily initialised for perf reasons - * @type {Set | null} + * @type {Map> | null} */ stale_readers = null; @@ -413,7 +415,7 @@ export class Batch { owner.waiter = this; var waiting = (this.waiting ??= { batches: new Set(), reactions: new Map() }); waiting.batches.add(owner); - if (Object.hasOwn(signal, 'deps')) waiting.reactions.set(signal, owner); + if (has_own_property.call(signal, 'deps')) waiting.reactions.set(signal, owner); } /** @@ -484,7 +486,7 @@ export class Batch { if (!async_mode_flag || this.is_fork) return false; - var is_source = !Object.hasOwn(signal, 'deps'); + var is_source = !has_own_property.call(signal, 'deps'); if (!is_source && (signal.f & (DERIVED | ASYNC | BLOCK_EFFECT | USER_EFFECT)) === 0) { return false; @@ -679,8 +681,23 @@ export class Batch { } other.#scheduled = []; - this.stale_readers = transfer_set(this.stale_readers, other.stale_readers); - other.stale_readers = null; + if (other.stale_readers !== null) { + this.stale_readers ??= new Map(); + + for (const [reader, seen] of other.stale_readers) { + var observed = this.stale_readers.get(reader); + + if (observed === undefined) { + this.stale_readers.set(reader, seen); + } else { + for (const [signal, value] of seen) { + // TODO could a newer value have been observed by this and other is older? + observed.set(signal, value); + } + } + } + other.stale_readers = null; + } if (other.waiting !== null) { this.waiting ??= { batches: new Set(), reactions: new Map() }; @@ -1179,12 +1196,35 @@ export class Batch { var batch = Batch.ensure(); - for (const reader of readers) { + for (const [reader, seen] of readers) { var flags = reader.f; if ((flags & (DESTROYED | INERT | DIRTY)) !== 0) continue; - set_signal_status(reader, DIRTY); + // Only re-run readers that are actually affected by the commit: a + // reader observed specific values through this batch's overlay. If + // each of those matches the committed value (the write was reverted, + // or a derived recomputed to an equal value), or the reader no + // longer depends on it, the reader's world didn't change + var status = CLEAN; + + for (const [signal, value] of seen) { + if (reader.deps === null || !includes.call(reader.deps, signal)) continue; + + if ((signal.f & (DIRTY | MAYBE_DIRTY)) !== 0) { + // a derived that hasn't been revalidated with the committed + // values yet — the reader's own validation will recompute it + // (with equality applying) via `is_dirty` + status = MAYBE_DIRTY; + } else if (signal.v !== value) { + status = DIRTY; + break; + } + } + + if (status === CLEAN) continue; + + set_signal_status(reader, status); if ((flags & DERIVED) !== 0) { // invalidate anything that depends on the derived diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 3829dab865..e94564133b 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -738,12 +738,21 @@ export function get(signal) { if (override !== undefined) { // if we're seeing another live batch's pre-write world, it must - // re-run us with the real values when it commits + // re-run us with the real values when it commits (if the value + // we saw turns out to differ from the committed one) var override_owner = override[1]; if (override_owner !== null && active_reaction !== null && !untracking) { override_owner = override_owner.resolved(); - (override_owner.stale_readers ??= new Set()).add(active_reaction); + + var readers = (override_owner.stale_readers ??= new Map()); + var seen = readers.get(active_reaction); + + if (seen === undefined) { + readers.set(active_reaction, (seen = new Map())); + } + + seen.set(signal, override[0]); } return override[0]; diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js new file mode 100644 index 0000000000..0e741d3bbf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_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 [x, y, shift] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + '

0

' + ); + assert.deepEqual(logs, []); + + // write x — the batch is pending on its async expression + x.click(); + await tick(); + assert.deepEqual(logs, []); + + // an independent batch runs the effect, which 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 === 1 — the effect saw a + // stale value and must re-run with the real one + shift.click(); + await tick(); + assert.deepEqual(logs, ['effect 0 1', 'effect 1 1']); + assert.htmlEqual( + target.innerHTML, + '

1

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte new file mode 100644 index 0000000000..3a9323d8ce --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte @@ -0,0 +1,23 @@ + + +

{await delay(x)}

+ + + 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-dropped-dep/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/_config.js new file mode 100644 index 0000000000..8aa913d73d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [x, y, shift] = target.querySelectorAll('button'); + + assert.deepEqual(logs, ['effect _ 0']); + + // write x — the batch is pending on its async expression + x.click(); + await tick(); + assert.deepEqual(logs, ['effect _ 0']); + + // the effect reads `x` through the pending batch's overlay + // (seeing the held-back value 0) + y.click(); + await tick(); + assert.deepEqual(logs, ['effect _ 0', 'effect 0 1']); + + // the effect re-runs and no longer depends on `x` at all + y.click(); + await tick(); + assert.deepEqual(logs, ['effect _ 0', 'effect 0 1', 'effect _ 2']); + + // the pending batch settles and commits x = 1 — the effect no longer + // depends on `x`, so it should not re-run + shift.click(); + await tick(); + assert.deepEqual(logs, ['effect _ 0', 'effect 0 1', 'effect _ 2']); + assert.htmlEqual( + target.innerHTML, + '

1

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/main.svelte new file mode 100644 index 0000000000..4d9d616293 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/main.svelte @@ -0,0 +1,24 @@ + + +

{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-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-starvation-fork/_config.js b/packages/svelte/tests/runtime-runes/samples/async-starvation-fork/_config.js new file mode 100644 index 0000000000..63cc35d1d0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-starvation-fork/_config.js @@ -0,0 +1,47 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, warnings }) { + await tick(); + const [increment, forkButton, commit, resolve_sealed, resolve_latest] = + target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + // restart the merged batch often enough that it becomes sealed + // (MAX_ENTANGLED_RESTARTS) — subsequent work must wait behind it + for (let i = 0; i < 11; i += 1) { + increment.click(); + await tick(); + } + + assert.htmlEqual(p.innerHTML, '0:0'); + assert.equal(warnings.length, 0); + + // fork writes count in its own world and runs the async expression + // with count = 111 + forkButton.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0:0'); + + // committing the fork claims its validated effect, which is owned by + // the sealed batch — the commit has to wait behind it + commit.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0:0'); + assert.isTrue(warnings.length === 1 && warnings[0].includes('Your app is stuck in a loop')); + + // the sealed batch settles and commits its world (count = 11), then + // releases the waiting fork-commit batch, which is still pending on + // the fork's in-flight run (111) + resolve_sealed.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '11:11'); + + // resolving the fork's run commits the fork-committed world — the + // two halves of the paragraph must not tear + resolve_latest.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '111:111'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-starvation-fork/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-starvation-fork/main.svelte new file mode 100644 index 0000000000..99e6fe0ac1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-starvation-fork/main.svelte @@ -0,0 +1,23 @@ + + +

{count}:{await delay(count)}

+ + + + +