set deriveds to clean when it's latest value (avoids overfiring); save maybe_dirty deriveds in deferred batches (avoids underfiring that would otherwise happen as a result of the first fix)

async-another-try
Simon Holthausen 2 days ago
parent 06cfc31f8d
commit 54d8c3d843
No known key found for this signature in database

@ -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<Reaction, number>}
*/
#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);

@ -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);
}
}
/**

@ -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;

@ -0,0 +1,20 @@
<script>
let { revision, delay } = $props();
const assessment = $derived(await delay('assessment', revision));
// Keep the batch pending while the if blocks read parity.
const schedule = $derived(await delay('schedule', assessment));
const parity = $derived.by(() => {
console.log(['parity', assessment]);
return assessment % 2;
});
const matched = $derived.by(() => {
console.log(['matched', assessment, schedule]);
return assessment === schedule;
});
</script>
<p>{assessment}/{parity}</p>
{#if parity}<p>odd</p>{/if}
{#if schedule >= 0 && parity}<p>also odd</p>{/if}
{#if matched}<p>matched</p>{/if}
<button onclick={() => console.log(['read', parity])}>read parity</button>

@ -0,0 +1,76 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>update</button>
<button>resolve assessment 2</button>
<button>resolve schedule 2</button>
<button>resolve assessment 1</button>
`;
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}<p>0/0</p><p>matched</p><button>read parity</button>`
);
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}<p>2/0</p><p>matched</p><button>read parity</button>`
);
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}<p>2/0</p><p>matched</p><button>read parity</button>`
);
// 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]]);
}
});

@ -0,0 +1,25 @@
<script>
import Child from './Child.svelte';
let revision = $state(0);
const pending = new Map();
function delay(kind, value) {
console.log([kind, value]);
if (value === 0) return value;
return new Promise(resolve => pending.set(`${kind}:${value}`, () => resolve(value)));
}
function resolve(kind, value) {
pending.get(`${kind}:${value}`)?.();
}
</script>
<button onclick={() => revision++}>update</button>
<button onclick={() => resolve('assessment', 2)}>resolve assessment 2</button>
<button onclick={() => resolve('schedule', 2)}>resolve schedule 2</button>
<button onclick={() => resolve('assessment', 1)}>resolve assessment 1</button>
<svelte:boundary>
<Child {revision} {delay} />
{#snippet pending()}loading{/snippet}
</svelte:boundary>

@ -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,
'<button>update</button><button>resolve a</button><button>resolve b</button><p>2:0</p>'
);
}
});

@ -0,0 +1,22 @@
<script>
let count = $state(0);
const parity = $derived.by(() => {
console.log(count);
return count % 2;
});
const pending = new Map();
function delay(name, value) {
if (value === 0) return value;
return new Promise(resolve => pending.set(name, () => resolve(value)));
}
</script>
<button onclick={() => count += 2}>update</button>
<button onclick={() => pending.get('a')?.()}>resolve a</button>
<button onclick={() => pending.get('b')?.()}>resolve b</button>
<p>{await delay('a', count)}:{parity}</p>
{#if await delay('b', count)}
{#if parity}<p>odd</p>{/if}
{/if}

@ -0,0 +1,37 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
`;
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}<p>0:0</p><p>0</p>`);
// 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}<p>0:0</p><p>0</p>`);
// 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}<p>1:2</p><p>0</p>`);
resolve_b.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>1:4</p><p>1</p><span>ready</span>`);
}
});

@ -0,0 +1,21 @@
<script>
let a = $state(0);
let b = $state(0);
const sum = $derived(a + b);
const doubled = $derived(sum * 2);
const pending = new Map();
function delay(name, value) {
if (value === 0) return value;
return new Promise(resolve => pending.set(name, () => resolve(value)));
}
</script>
<button onclick={() => a++}>a</button>
<button onclick={() => b++}>b</button>
<button onclick={() => pending.get('a')?.()}>resolve a</button>
<button onclick={() => pending.get('b')?.()}>resolve b</button>
<p>{await delay('a', a)}:{doubled}</p>
<p>{await delay('b', b)}</p>
{#if b && doubled}<span>ready</span>{/if}
Loading…
Cancel
Save