more edge case fixes

async-another-try
Simon Holthausen 5 days ago
parent ba4d37a4b4
commit 7005aae212
No known key found for this signature in database

@ -714,6 +714,12 @@ export class Batch {
if (b !== this) this.dependent.add(b);
}
for (let b = first_batch; b !== null; b = b.next) {
if (b.dependent.has(batch)) {
b.dependent.add(this);
}
}
for (const c of batch.#commit_callbacks) {
this.oncommit(() => c(batch));
}
@ -836,6 +842,10 @@ export class Batch {
batch.current.delete(source);
if (![...batch.current.values()].some((v) => !v[1])) {
// The real world has overtaken every write of this fork, so it is obsolete. Discard it
// right away (its speculative branches must not be adopted by anyone), and empty
// `current` so that `commit()` can tell this apart from a user-initiated discard
batch.current.clear();
batch.discard();
} else {
if (current) batch.current.set(source, current);
@ -1489,6 +1499,15 @@ export function fork(fn) {
return;
}
if (batch.current.size === 0) {
// Nothing to commit: either the fork never wrote anything (e.g. it assigned a value
// that was already current), or the real world has since written to every source
// it did write to and the fork was discarded as obsolete (see `notify_fork`)
committed = true;
batch.discard();
return;
}
if (!batch.linked) {
e.fork_discarded();
}

@ -602,17 +602,10 @@ export function get(signal) {
// rather than updating `new_deps`, which creates GC cost
if (new_deps === null && deps !== null && deps[skipped_deps] === signal) {
skipped_deps++;
} else if (new_deps === null) {
new_deps = [signal];
} else {
if (new_deps === null) {
new_deps = [signal];
} else {
new_deps.push(signal);
}
// Only a signal that wasn't a dependency of this reaction before counts as new —
// reading existing dependencies in a different order must not (it would make
// the reaction see the latest value instead of its batch's view, see below)
first_time = deps === null || !includes.call(deps, signal);
new_deps.push(signal);
}
}
} else {
@ -793,18 +786,21 @@ export function get(signal) {
}
}
// A reaction that reads a signal for the first time must see the latest value, rather than
// this batch's view, if that view could hide the write of an _earlier_ batch — the user's
// program made that write before this batch's writes, so hiding it could e.g. crash a newly
// created branch (see `async-state-read-new-dependency`). Earlier batches' writes are hidden
// only while flushing a committing batch (`previous_batch` is set, see `apply(true)`) and in
// eager batches (which hide every other batch). Everywhere else `batch_values` only hides
// _later_ batches' writes, which is correct even for new readers: that's the state the
// program was in when this batch's writes happened.
var see_latest = first_time && (previous_batch !== null || current_batch?.is_eager);
if (!see_latest && batch_values?.has(signal)) {
return batch_values.get(signal);
if (batch_values?.has(signal)) {
// A reaction that reads a signal for the first time while flushing render effects or
// during an eager batch needs to show the latest value, because maybe it would crash
// with the old version (see test `async-state-read-new-dependency` and its variants).
var see_latest =
(previous_batch !== null || current_batch?.is_eager) &&
(first_time ||
(active_reaction !== null &&
!untracking &&
(active_reaction.f & REACTION_IS_UPDATING) !== 0 &&
(active_reaction.deps === null || !includes.call(active_reaction.deps, signal))));
if (!see_latest) {
return batch_values.get(signal);
}
}
if ((signal.f & ERROR_VALUE) !== 0) {

@ -0,0 +1,22 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `<button>preload</button><button>other</button><button>commit</button>`;
// If a fork is auto-discarded it should not throw on user-commit.
export default test({
mode: ['client'],
async test({ assert, target }) {
const [preload, other, commit] = target.querySelectorAll('button');
preload.click();
await tick();
other.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>true 1</p>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>true 1</p>`);
}
});

@ -0,0 +1,14 @@
<script>
import { fork } from 'svelte';
let open = $state(true);
let other = $state(0);
let pending;
let error = $state('');
</script>
<button onclick={() => { pending ??= fork(() => { open = true; }); }}>preload</button>
<button onclick={() => other++}>other</button>
<button onclick={() => { pending.commit().catch((e) => (error = e.message)); pending = null; }}>commit</button>
<p>{open} {other} {error}</p>

@ -0,0 +1,42 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork</button>
<button>x = 5</button>
<button>commit</button>
<button>shift</button>
`;
// A fork writes `x`, then the real world writes `x` as well (with async work still pending).
// The fork's write is overtaken and it has nothing left to commit — `commit()` must resolve
// rather than throw `fork_discarded`, and the real world's value wins
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [fork, x5, commit, shift] = target.querySelectorAll('button');
fork.click(); // speculative: delay(10)
await tick();
x5.click(); // real: delay(5); the fork adopts x = 5 and re-runs: delay(5)
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
shift.click(); // the fork's obsolete delay(10)
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
shift.click(); // the real delay(5)
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>5</p>`);
shift.click(); // the fork's delay(5), rejected when the fork was cleaned up
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>5</p>`);
}
});

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

@ -0,0 +1,49 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>A0</button>
<button>A</button>
<button>B</button>
<button>resolve q</button>
<button>resolve p</button>
<button>resolve t</button>
<button>resolve r</button>
<button>init</button>
`;
// Test ensure dependencies on earlier batches are also merged correctly
export default test({
async test({ assert, target }) {
const [A0, A, B, resolve_q, resolve_p, resolve_t, resolve_r, init] =
target.querySelectorAll('button');
init.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>q0 r0 p0 t0 s0</p>`);
A0.click(); // q=1, r=1 -> slow(q=1), slow(r=1)
await tick();
A.click(); // q=2, p=1, s=1 -> slow(q=2), slow(p=1); depends on A0
await tick();
B.click(); // s=2, t=1 -> slow(t=1); depends on A
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>q0 r0 p0 t0 s0</p>`);
// A's runs resolve -> A merges into A0, which is still waiting on slow(r=1)
resolve_q.click();
resolve_p.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>q0 r0 p0 t0 s0</p>`);
// B's run resolves -> B must wait for A0
resolve_t.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>q0 r0 p0 t0 s0</p>`);
// A0's run resolves -> everything commits at once
resolve_r.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>q2 r1 p1 t1 s2</p>`);
}
});

@ -0,0 +1,27 @@
<script>
let q = $state(0);
let r = $state(0);
let p = $state(0);
let s = $state(0);
let t = $state(0);
const resolvers = { q: [], r: [], p: [], t: [] };
function slow(kind, v) {
return new Promise((resolve) => resolvers[kind].push(() => resolve(v)));
}
</script>
<button onclick={() => { q++; r++; }}>A0</button>
<button onclick={() => { q++; p++; s++; }}>A</button>
<button onclick={() => { s++; t++; }}>B</button>
<button onclick={() => resolvers.q.pop()?.()}>resolve q</button>
<button onclick={() => resolvers.p.pop()?.()}>resolve p</button>
<button onclick={() => resolvers.t.pop()?.()}>resolve t</button>
<button onclick={() => resolvers.r.shift()?.()}>resolve r</button>
<button onclick={() => { for (const k in resolvers) resolvers[k].shift()?.(); }}>init</button>
<svelte:boundary>
{#snippet pending()}loading{/snippet}
<p>q{await slow('q', q)} r{await slow('r', r)} p{await slow('p', p)} t{await slow('t', t)} s{s}</p>
</svelte:boundary>

@ -21,7 +21,7 @@ export default test({
<button>update</button>
<button>show</button>
<button>resolve</button>
<p>1</p>
<p>2</p>
`
);
@ -34,7 +34,7 @@ export default test({
<button>update</button>
<button>show</button>
<button>resolve</button>
<p>1</p>
<p>2</p>
`
);
}

@ -14,4 +14,5 @@
{await wait(value)}
<p>{show ? value.x : ''}</p>
<!-- read value.x twice to ensure it reads latest value not just the first time -->
<p>{show ? value.x + value.x : ''}</p>
Loading…
Cancel
Save