diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 60092edd20..6002752004 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -261,11 +261,11 @@ export class Batch { * Leaf (i.e. not block/async) effects (dirty and maybe_dirty) are stored because we need * to reset their status when a batch becomes pending, to not pollute other batches. * - * Dirty deriveds (but not maybe_dirty deriveds) are stored because a derived that definitely - * should execute might get executed in the meantime in another batch (they are lazy, so a DIRTY derived is - * not guaranteed to run immediately). Relying on wv_values is insufficient because if this derived has stale - * dependencies in this batch but is executed with latest dependencies elsewhere, the wv is bumped and would - * incorrectly say "hey we don't need to rerun this" in the context of this batch. + * Dirty and maybe_dirty deriveds are stored because they might be evaluated and + * marked globally clean by another batch before this batch resumes. Restoring + * only DIRTY deriveds is insufficient: a clean parent would skip their checks. + * Relying on wv_values alone is also insufficient, because a later evaluation + * with newer inputs could incorrectly prevent reevaluation with this batch's inputs. * @type {Map} */ #dirty_reactions = new Map(); @@ -1070,6 +1070,12 @@ export class Batch { } } + /** @param {Reaction} reaction */ + remove_dirty_reaction(reaction) { + // Check is done for perf reasons + if (this.#dirty_reactions.size !== 0) this.#dirty_reactions.delete(reaction); + } + /** @param {(batch: Batch) => void} fn */ oncommit(fn) { this.#commit_callbacks.add(fn); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 3449613c5d..144e883a77 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -420,6 +420,7 @@ export function update_derived(derived) { // deriveds without dependencies should never be recomputed if (derived.deps === null) { set_signal_status(derived, CLEAN); + (previous_batch ?? current_batch)?.remove_dirty_reaction(derived); return; } } else if (batch_values?.has(derived) && !derived.equals(batch_values?.get(derived))) { @@ -432,8 +433,9 @@ export function update_derived(derived) { return; } - // During time traveling we don't want to reset the status so that - // traversal of the graph in the other batches still happens + // During time travelling, keep stale results batch-local. A result computed + // from the latest inputs can be marked globally clean, even if it is unchanged + // and its write version therefore remains below its dependencies' versions. if ( batch_values !== null || // "read outside of reactivity", e.g. in an event handler @@ -444,10 +446,24 @@ export function update_derived(derived) { if (effect_tracking() || current_batch?.is_fork) { batch_values?.set(derived, value); } - if (derived.v !== UNINITIALIZED) set_signal_status(derived, MAYBE_DIRTY); + var is_latest_value = + !current_batch?.is_fork && + value === derived.v && + !derived.deps?.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v); + + if (is_latest_value) { + update_derived_status(derived); + } else if (derived.v !== UNINITIALIZED) { + set_signal_status(derived, MAYBE_DIRTY); + } } else { update_derived_status(derived); } + + if ((derived.f & CLEAN) !== 0) { + // Other batches may still need to check their older inputs on resume. + (previous_batch ?? current_batch)?.remove_dirty_reaction(derived); + } } /** diff --git a/packages/svelte/src/internal/client/reactivity/utils.js b/packages/svelte/src/internal/client/reactivity/utils.js index 0acf9f2cb4..9620c725fe 100644 --- a/packages/svelte/src/internal/client/reactivity/utils.js +++ b/packages/svelte/src/internal/client/reactivity/utils.js @@ -35,6 +35,8 @@ function defer_derived(value, dirty_reactions) { if ((derived.f & DIRTY) !== 0) { dirty_reactions.set(derived, DIRTY); set_signal_status(derived, MAYBE_DIRTY); + } else if (!dirty_reactions.has(derived)) { + dirty_reactions.set(derived, MAYBE_DIRTY); } if (derived.deps === null) return; diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte new file mode 100644 index 0000000000..e8da9df1f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte @@ -0,0 +1,20 @@ + + +

{assessment}/{parity}

+{#if parity}

odd

{/if} +{#if schedule >= 0 && parity}

also odd

{/if} +{#if matched}

matched

{/if} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js new file mode 100644 index 0000000000..ec47264eb9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js @@ -0,0 +1,76 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + await tick(); + const [update, resolve_a_2, resolve_schedule_2, resolve_a_1, read] = + target.querySelectorAll('button'); + assert.htmlEqual( + target.innerHTML, + `${buttons}

0/0

matched

` + ); + assert.deepEqual(logs, [ + ['assessment', 0], + ['schedule', 0], + ['parity', 0], + ['matched', 0, 0] + ]); + logs.length = 0; + + update.click(); + await tick(); + update.click(); + await tick(); + + // The dependency changes, but parity stays 0 and its write version stays + // unchanged. Both if blocks must share one evaluation in this batch's view. + resolve_a_2.click(); + await tick(); + assert.deepEqual(logs, [ + ['assessment', 1], + ['assessment', 2], + ['schedule', 2], + ['parity', 2], + ['matched', 2, 0] + ]); + logs.length = 0; + + // Resume the same batch and read parity again from the second if block. + // Its dependencies have not changed since its previous evaluation. + // In contrast, matched depends on schedule and must be invalidated. + resolve_schedule_2.click(); + await tick(); + // Committing also merges into the earlier batch and flushes the text effect, + // which must reuse the clean parity rather than evaluating it again. + assert.deepEqual(logs, [['matched', 2, 2]]); + assert.htmlEqual( + target.innerHTML, + `${buttons}

2/0

matched

` + ); + logs.length = 0; + + // The superseded result must not cause any further evaluations. + resolve_a_1.click(); + await tick(); + assert.deepEqual(logs, []); + assert.htmlEqual( + target.innerHTML, + `${buttons}

2/0

matched

` + ); + + // Once the competing batches are gone, parity must be globally clean. + // An event-handler read has no batch-local status to fall back on. + read.click(); + assert.deepEqual(logs, [['read', 0]]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte new file mode 100644 index 0000000000..1ef51268f2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte @@ -0,0 +1,25 @@ + + + + + + + + + + {#snippet pending()}loading{/snippet} + 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-deferred-maybe-dirty/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/_config.js new file mode 100644 index 0000000000..65178b5f32 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [a, b, resolve_a, resolve_b] = target.querySelectorAll('button'); + assert.htmlEqual(target.innerHTML, `${buttons}

0:0

0

`); + + // sum is DIRTY and doubled is MAYBE_DIRTY, but neither runs while a is pending. + a.click(); + await tick(); + + // The if block evaluates both deriveds with the latest inputs and marks them clean. + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0:0

0

`); + + // The earlier batch must restore doubled's MAYBE_DIRTY status as well as sum's + // DIRTY status, otherwise it reuses the cached value from before a changed. + resolve_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1:2

0

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

1:4

1

ready`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte new file mode 100644 index 0000000000..15d2d6ffc6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{await delay('a', a)}:{doubled}

+

{await delay('b', b)}

+{#if b && doubled}ready{/if}