more tests + bugfix

entangle-batches-2
Simon Holthausen 2 months ago
parent a9aef844ab
commit 1ce167a7cb
No known key found for this signature in database

@ -22,7 +22,7 @@ import {
TEMPLATE_EXPRESSION TEMPLATE_EXPRESSION
} from '#client/constants'; } from '#client/constants';
import { async_mode_flag } from '../../flags/index.js'; 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 { import {
active_reaction, active_reaction,
get, get,
@ -168,6 +168,7 @@ export class Batch {
* tuples. `owner` is the live batch whose pre-write value we are seeing, * 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 * or `null` if the value belongs to this batch. For forks this also stores
* world-local derived values between activations. * 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<Value, [any, Batch | null]> | null} * @type {Map<Value, [any, Batch | null]> | null}
*/ */
values = null; values = null;
@ -280,10 +281,11 @@ export class Batch {
/** /**
* Reactions that observed the pre-write world of this batch via its active * Reactions that observed the pre-write world of this batch via its active
* overlay while it was pending. When this batch commits, they * overlay while it was pending, mapped to the values they saw. When this
* re-run with the real values. * batch commits, readers whose observed values differ from the committed
* ones re-run with the real values.
* Lazily initialised for perf reasons * Lazily initialised for perf reasons
* @type {Set<Reaction> | null} * @type {Map<Reaction, Map<Value, any>> | null}
*/ */
stale_readers = null; stale_readers = null;
@ -413,7 +415,7 @@ export class Batch {
owner.waiter = this; owner.waiter = this;
var waiting = (this.waiting ??= { batches: new Set(), reactions: new Map() }); var waiting = (this.waiting ??= { batches: new Set(), reactions: new Map() });
waiting.batches.add(owner); 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; 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) { if (!is_source && (signal.f & (DERIVED | ASYNC | BLOCK_EFFECT | USER_EFFECT)) === 0) {
return false; return false;
@ -679,8 +681,23 @@ export class Batch {
} }
other.#scheduled = []; other.#scheduled = [];
this.stale_readers = transfer_set(this.stale_readers, other.stale_readers); if (other.stale_readers !== null) {
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) { if (other.waiting !== null) {
this.waiting ??= { batches: new Set(), reactions: new Map() }; this.waiting ??= { batches: new Set(), reactions: new Map() };
@ -1179,12 +1196,35 @@ export class Batch {
var batch = Batch.ensure(); var batch = Batch.ensure();
for (const reader of readers) { for (const [reader, seen] of readers) {
var flags = reader.f; var flags = reader.f;
if ((flags & (DESTROYED | INERT | DIRTY)) !== 0) continue; 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) { if ((flags & DERIVED) !== 0) {
// invalidate anything that depends on the derived // invalidate anything that depends on the derived

@ -738,12 +738,21 @@ export function get(signal) {
if (override !== undefined) { if (override !== undefined) {
// if we're seeing another live batch's pre-write world, it must // 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]; var override_owner = override[1];
if (override_owner !== null && active_reaction !== null && !untracking) { if (override_owner !== null && active_reaction !== null && !untracking) {
override_owner = override_owner.resolved(); 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]; return override[0];

@ -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,
'<p>0</p><button>x</button><button>y</button><button>shift</button>'
);
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,
'<p>1</p><button>x</button><button>y</button><button>shift</button>'
);
}
});

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

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

@ -0,0 +1,26 @@
<script>
let x = $state(1);
let show = $state(false);
let pend = false;
const deferred = [];
let positive = $derived(x > 0);
function delay(value) {
if (!pend) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
$effect(() => {
if (show) {
console.log(`positive ${positive}`);
}
});
</script>
<p>{positive}</p>
<p>{await delay(x)}</p>
<button onclick={() => { pend = true; x += 1; }}>x</button>
<button onclick={() => (show = true)}>show</button>
<button onclick={() => deferred.shift()?.()}>shift</button>

@ -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,
'<p>1</p><button>x</button><button>y</button><button>shift</button>'
);
}
});

@ -0,0 +1,24 @@
<script>
let x = $state(0);
let y = $state(0);
const deferred = [];
function delay(value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
$effect(() => {
if (y === 1) {
console.log(`effect ${x} ${y}`);
} else {
console.log(`effect _ ${y}`);
}
});
</script>
<p>{await delay(x)}</p>
<button onclick={() => x++}>x</button>
<button onclick={() => y++}>y</button>
<button onclick={() => deferred.shift()?.()}>shift</button>

@ -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,
'<p>0</p><button>revert</button><button>y</button><button>shift</button>'
);
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']);
}
});

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

@ -0,0 +1,36 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = '<button>count</button><button>eager</button><button>shift</button>';
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, `<p>-1</p>${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, `<p>0</p><span>1</span>${buttons}`);
}
});

@ -0,0 +1,26 @@
<script>
let count = $state(-1);
let eag = $state(0);
const deferred = [];
function delay(value) {
if (value < 0) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
function log(value) {
console.log(`inner ${value}`);
return value >= 0;
}
</script>
<p>{await delay(count)}</p>
{#if count >= 0}
{#if log($state.eager(eag))}
<span>{eag}</span>
{/if}
{/if}
<button onclick={() => count++}>count</button>
<button onclick={() => eag++}>eager</button>
<button onclick={() => deferred.shift()?.()}>shift</button>

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

@ -0,0 +1,23 @@
<script>
import { fork } from 'svelte';
let count = $state(0);
let f;
const deferred = new Map();
function delay(value) {
if (value === 0) return value;
return new Promise((resolve) => {
deferred.set(value, () => resolve(value));
});
}
</script>
<p>{count}:{await delay(count)}</p>
<button onclick={() => count++}>increment</button>
<button onclick={() => { f = fork(() => { count += 100; }); }}>fork</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => deferred.get(11)?.()}>resolve sealed</button>
<button onclick={() => deferred.get(count)?.()}>resolve latest</button>
Loading…
Cancel
Save