simplify, fix, more tests

entangle-batches-2
Simon Holthausen 5 days ago
parent 072b97d426
commit b96a3dad45
No known key found for this signature in database

@ -52,14 +52,6 @@ export const EFFECT_OFFSCREEN = 1 << 25;
* should not be used for any other purpose!
*/
export const WAS_MARKED = 1 << 16;
/**
* A derived created for a template expression. These are leaves of the reactivity
* graph (there could theoretically be one per read value), so they don't entangle
* batches instead, while multiple batches exist, they are evaluated per-world
* without caching
*/
export const TEMPLATE_EXPRESSION = 1 << 26;
// Flags used for async
export const REACTION_IS_UPDATING = 1 << 21;
export const ASYNC = 1 << 22;

@ -1,5 +1,5 @@
/** @import { Blocker, Effect, Source, Value } from '#client' */
import { DESTROYED, STALE_REACTION, TEMPLATE_EXPRESSION } from '#client/constants';
import { DESTROYED, STALE_REACTION } from '#client/constants';
import { DEV } from 'esm-env';
import {
component_context,
@ -8,7 +8,6 @@ import {
set_component_context,
set_dev_stack
} from '../context.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { invoke_error_boundary } from '../error-handling.js';
import {
active_effect,
@ -39,13 +38,7 @@ export function flatten(blockers, sync, async, fn) {
// Filter out already-settled blockers - no need to wait for them
var pending = blockers.filter((b) => !b.settled);
var deriveds = sync.map((expression) => {
var signal = d(expression);
// template expressions are leaves of the reactivity graph — they don't
// entangle batches, and are evaluated per-world while batches overlap
signal.f |= TEMPLATE_EXPRESSION;
return signal;
});
var deriveds = sync.map((expression) => d(expression));
if (DEV) {
deriveds.forEach((d, i) => {

@ -18,8 +18,7 @@ import {
MANAGED_EFFECT,
REACTION_RAN,
DESTROYING,
USER_EFFECT,
TEMPLATE_EXPRESSION
USER_EFFECT
} from '#client/constants';
import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, has_own_property, includes } from '../../shared/utils.js';
@ -492,9 +491,6 @@ export class Batch {
return false;
}
// template expression deriveds are leaves — they don't entangle
if ((signal.f & TEMPLATE_EXPRESSION) !== 0) return false;
var owner = signal.batch && signal.batch.resolved();
if (this.is_eager) {
@ -1169,6 +1165,14 @@ export class Batch {
var owner = effect.batch && effect.batch.resolved();
if (owner !== this) continue;
if (!depends_on_fork_values(effect, fork)) {
// the fork's view of every dependency matches the committed
// state — the latest real run is valid for the fork's world
// too, so it doesn't need to re-run speculatively
fork.fork_effects.set(effect, effect_versions.get(effect) ?? 0);
continue;
}
fork.schedule(effect);
var effects = (forks ??= new Map()).get(fork);
if (effects === undefined) forks.set(fork, [effect]);
@ -1666,6 +1670,31 @@ export function eager(fn) {
return value;
}
/**
* Whether `reaction` depends directly or through deriveds on a signal
* whose value in `fork`'s world differs from the real one (i.e. one of the
* fork's own speculative writes)
* @param {Reaction} reaction
* @param {Batch} fork
* @returns {boolean}
*/
function depends_on_fork_values(reaction, fork) {
var deps = reaction.deps;
if (deps === null) return false;
for (var i = 0; i < deps.length; i++) {
var dep = deps[i];
if (fork.current.has(dep)) return true;
if ((dep.f & DERIVED) !== 0 && depends_on_fork_values(/** @type {Derived} */ (dep), fork)) {
return true;
}
}
return false;
}
/**
* When a fork is committed, deriveds affected by its writes must recompute with
* the now-committed values, and affected effects must be checked. Async and
@ -1723,7 +1752,20 @@ function mark_committed_reactions(value, batch, marked, status) {
owner.linked &&
!owner.is_fork
) {
batch.claim(effect);
if (batch.current.has(value) && batch.current.get(value) === value.v) {
// the committed value matches the real (pending) one the
// in-flight run used — entangling the two batches is enough
batch.claim(effect);
} else {
// the newer in-flight run of this effect was computed with
// pre-commit values — entangle with its batch (via `schedule`
// -> `claim`) and re-run it with the committed values
if ((reaction.f & DIRTY) === 0) {
set_signal_status(reaction, status);
}
batch.schedule(effect);
}
}
} else if ((flags & DIRTY) === 0) {
set_signal_status(reaction, status);

@ -23,8 +23,7 @@ import {
ERROR_VALUE,
WAS_MARKED,
MANAGED_EFFECT,
REACTION_RAN,
TEMPLATE_EXPRESSION
REACTION_RAN
} from './constants.js';
import { old_values } from './reactivity/sources.js';
import {
@ -173,25 +172,24 @@ export function is_dirty(reaction) {
for (var i = 0; i < length; i++) {
var dependency = dependencies[i];
if (active_batch !== null && active_batch.values !== null && (dependency.f & DERIVED) !== 0) {
var is_template = (dependency.f & TEMPLATE_EXPRESSION) !== 0;
if (is_template || claimed_by_other(/** @type {Derived} */ (dependency)) !== null) {
// deriveds that are evaluated per-world (in `get`) while multiple
// batches exist: if dirty, treat them as changed — the dirtiness
// may originate from a write in our own world, and re-running the
// reaction is harmless otherwise
if ((dependency.f & (DIRTY | MAYBE_DIRTY)) !== 0) {
return true;
}
if (!is_template) {
// a clean derived belonging to another live batch's world is
// unchanged in ours — the owner's recompute (which may have
// bumped its write version) doesn't affect our world
continue;
}
if (
active_batch !== null &&
active_batch.values !== null &&
(dependency.f & DERIVED) !== 0 &&
claimed_by_other(/** @type {Derived} */ (dependency)) !== null
) {
// deriveds belonging to another live batch's world are evaluated
// per-world (in `get`): if dirty, treat them as changed — the
// dirtiness may originate from a write in our own world, and
// re-running the reaction is harmless otherwise
if ((dependency.f & (DIRTY | MAYBE_DIRTY)) !== 0) {
return true;
}
// a clean derived belonging to another live batch's world is
// unchanged in ours — the owner's recompute (which may have
// bumped its write version) doesn't affect our world
continue;
}
if (is_dirty(/** @type {Derived} */ (dependency))) {
@ -682,22 +680,18 @@ export function get(signal) {
return value;
}
// While multiple batches exist, some deriveds must be evaluated in the
// context of the current world, without touching their cached state:
// - deriveds belonging to another live batch's world must not be
// recomputed or have their status reset (the owning batch relies on
// both) — their value in this world follows from the active overlay
// - dirty template expression deriveds are leaves that can be shared
// between non-overlapping batches, so each world evaluates its own value
// While multiple batches exist, deriveds belonging to another live
// batch's world must be evaluated in the context of the current world,
// without touching their cached state — they must not be recomputed or
// have their status reset (the owning batch relies on both), and their
// value in this world follows from the active overlay
/** @type {Batch | null} */
var owner = null;
if (
active_batch !== null &&
active_batch.values !== null &&
((derived.f & TEMPLATE_EXPRESSION) !== 0
? (derived.f & (DIRTY | MAYBE_DIRTY)) !== 0
: (owner = claimed_by_other(derived)) !== null)
(owner = claimed_by_other(derived)) !== null
) {
// the world-local value is memoized in the active overlay (and invalidated
// there when dependencies change). Reads are registered with the owner

@ -0,0 +1,43 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons =
'<button>fork</button><button>y</button><button>shift</button><button>commit</button>';
export default test({
async test({ assert, target }) {
await tick();
const [forkButton, y, shift, commit] = target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// speculative world: x becomes 1, async expression runs with x + y = 1
forkButton.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// real world: y becomes 1, async expression runs with x + y = 1
// (computed with the pre-fork x = 0) — this run supersedes the
// fork's validation of the effect
y.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// commit the fork while the real batch is still pending — x = 1 is
// written, and the async expression must eventually re-run with the
// committed value, because its in-flight run used x = 0
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p>${buttons}`);
// resolve all in-flight runs (superseded ones are no-ops)
for (let i = 0; i < 4; i += 1) {
shift.click();
await tick();
}
// x = 1, y = 1 — anything else means the effect resolved with a value
// computed from stale inputs and was never re-run
assert.htmlEqual(target.innerHTML, `<p>2</p>${buttons}`);
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
let x = $state(0);
let y = $state(0);
let f;
const deferred = [];
function delay(value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<p>{await delay(x + y)}</p>
<button onclick={() => { f = fork(() => x++); }}>fork</button>
<button onclick={() => y++}>y</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => f.commit()}>commit</button>

@ -0,0 +1,42 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [forkButton, real, shift, discard] = target.querySelectorAll('button');
assert.deepEqual(logs, ['b 0']);
logs.length = 0;
// speculative world: fork writes b and c, runs the async expression
forkButton.click();
await tick();
assert.deepEqual(logs, ['b 1']);
// real world: b++ re-runs the async expression for real
real.click();
await tick();
assert.deepEqual(logs, ['b 1', 'b 1']);
// resolve the fork's in-flight run — the fork is still speculative,
// nothing should be committed or re-run
shift.click();
await tick();
assert.deepEqual(logs, ['b 1', 'b 1']);
// resolve the real run — the real batch commits b = 1. The fork's world
// value of `b` is also 1 (its own write, now also committed), so the
// fork's async expression sees unchanged inputs and should not re-run
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<p>1 0</p><button>fork</button><button>real</button><button>shift</button><button>discard</button>'
);
assert.deepEqual(logs, ['b 1', 'b 1']);
discard.click();
await tick();
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
let b = $state(0);
let c = $state(0);
let f;
const deferred = [];
function delay(_, value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<p>{await delay(console.log(`b ${b}`), b)} {c}</p>
<button onclick={() => { f = fork(() => { b += 1; c += 1; }); }}>fork</button>
<button onclick={() => b++}>real</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => f.discard()}>discard</button>

@ -23,10 +23,6 @@ export default test({
await tick();
assert.htmlEqual(p.innerHTML, 'a 0 | b 1 | c 0 | d 1');
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, 'a 0 | b 1 | c 0 | d 1');
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
@ -39,11 +35,6 @@ export default test({
await tick();
assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
// resolve the fork revalidation triggered by the real batch settling
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');
commit.click();
await tick();
assert.htmlEqual(p.innerHTML, 'a 1 | b 1 | c 1 | d 1');

@ -0,0 +1,29 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = '<button>a</button><button>b</button><button>shift</button>';
export default test({
async test({ assert, target }) {
await tick();
const [a, b, shift] = target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, `<p>0</p><p>0</p>${buttons}`);
// batch A writes `a` and pends on its async expression
a.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p><p>0</p>${buttons}`);
// batch B writes `b` — it shares the `{add(a, b)}` template expression
// with batch A, so the two describe the same world and are merged
b.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p><p>0</p>${buttons}`);
// the async expression settles — the merged batch commits both writes
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>1</p><p>2</p>${buttons}`);
}
});

@ -0,0 +1,22 @@
<script>
let a = $state(0);
let b = $state(0);
let pend = false;
const deferred = [];
function delay(value) {
if (!pend) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
function add(x, y) {
return x + y;
}
</script>
<p>{await delay(a)}</p>
<p>{add(a, b)}</p>
<button onclick={() => { pend = true; a++; }}>a</button>
<button onclick={() => b++}>b</button>
<button onclick={() => deferred.shift()?.()}>shift</button>

@ -0,0 +1,38 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = '<button>z</button><button>w</button><button>shift</button>';
export default test({
async test({ assert, target, logs }) {
await tick();
const [z, w, shift] = target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, `<p>true</p><p>0</p>${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, `<p>true</p><p>1</p>${buttons}`);
}
});

@ -0,0 +1,25 @@
<script>
let z = $state(1);
let w = $state(0);
let pend = false;
const deferred = [];
let c = $derived(z > 0);
function delay(value) {
if (!pend) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
function log(value) {
console.log(`eval ${value}`);
return value;
}
</script>
<p>{log(c)}</p>
<p>{await delay(w)}</p>
<button onclick={() => z++}>z</button>
<button onclick={() => { pend = true; w += 1; }}>w</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
Loading…
Cancel
Save