hold back new branches

entangle-batches-3
Simon Holthausen 2 weeks ago
parent 5839bd89a9
commit 5180d7fafa
No known key found for this signature in database

@ -50,6 +50,7 @@ import {
active_batch,
Batch,
claimed_by_other,
collected_effects,
current_batch,
flushSync,
previous_batch,
@ -708,7 +709,6 @@ export function get(signal) {
// have their status reset (the owning batch relies on both), and their
// value in this world follows from the active overlay
/** @type {Batch | null} */
// eslint-disable-next-line no-useless-assignment
var owner = null;
if (
@ -716,13 +716,20 @@ export function get(signal) {
active_batch.values !== 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
// batch — when it commits, the reader re-runs with the real values
if (!active_batch.values.has(derived)) {
if (is_unseen_read(derived, owner) && entangle(derived)) {
// a read with no history can entangle the two batches instead, so
// that they commit together — the derived is now part of this
// batch's world and behaves normally (below)
owner = null;
} else if (!active_batch.values.has(derived)) {
// the world-local value is memoized in the active overlay (and invalidated
// there when dependencies change). Reads are registered with the owner
// batch — when it commits, the reader re-runs with the real values
active_batch.values.set(derived, [execute_derived(derived), owner]);
}
} else {
}
if (owner === null) {
// connect disconnected deriveds if we are reading them inside an effect,
// or inside another derived that is already connected
var should_connect =
@ -764,8 +771,11 @@ export function get(signal) {
// reads outside a reaction during a flush happen in one-shot init
// code (e.g. a component initialising inside a newly-created
// branch). They have no dependency history and no re-run
// mechanism, so they see the latest value
// mechanism — entangle the batches if possible, so that both
// worlds commit together, and read the latest value
if ((signal.f & DERIVED) === 0) {
entangle(signal);
if ((signal.f & ERROR_VALUE) !== 0) {
throw signal.v;
}
@ -775,15 +785,10 @@ export function get(signal) {
} else if (!untracking) {
override_owner = override_owner.resolved();
var readers = (override_owner.stale_readers ??= new Map());
var seen = readers.get(active_reaction);
if (seen === undefined) {
readers.set(active_reaction, (seen = new Map()));
}
var seen = override_owner.stale_readers?.get(active_reaction);
// a reader keeps seeing the value it first observed while the owner is pending
if (seen.has(signal)) {
if (seen !== undefined && seen.has(signal)) {
return seen.get(signal);
}
@ -795,15 +800,31 @@ export function get(signal) {
(active_reaction.deps === null || !includes.call(active_reaction.deps, signal))
) {
// the reaction never depended on this signal before the owner's write —
// the pre-write world never contained this combination of values,
// so read the latest value instead
// the pre-write world never contained this combination of values.
// Entangle the batches if possible, so that both worlds commit
// together and this value is simply the batch's own write...
var entangled = entangle(signal);
if ((signal.f & ERROR_VALUE) !== 0) {
throw signal.v;
}
if (entangled) {
return signal.v;
}
// ...otherwise, read the latest value — the owner batch will
// re-run us when it commits, if the value we saw turns out
// to differ from the committed one
override_value = signal.v;
}
var readers = (override_owner.stale_readers ??= new Map());
if (seen === undefined) {
readers.set(active_reaction, (seen = new Map()));
}
seen.set(signal, override_value);
return override_value;
@ -821,6 +842,54 @@ export function get(signal) {
return signal.v;
}
/**
* Whether the current read of `signal` which is owned by another live batch
* has no history: the reader neither depended on the signal in a previous run,
* nor observed a value for it while the owner batch was pending. (Reads outside
* a reaction never have history.)
* @param {Value} signal
* @param {Batch} owner
* @returns {boolean}
*/
function is_unseen_read(signal, owner) {
if (active_reaction === null) return true;
if (untracking || (active_reaction.f & REACTION_IS_UPDATING) === 0) return false;
if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) {
return false;
}
var seen = owner.resolved().stale_readers?.get(active_reaction);
return seen === undefined || !seen.has(signal);
}
/**
* Attempt to entangle the active batch with the batch that owns `signal`,
* merging their worlds so that both commit together. This is only possible
* while the batch is traversing the effect tree before any of its UI has
* been committed. Returns true if the signal now belongs to the active
* batch's own world.
* @param {Value} signal
* @returns {boolean}
*/
function entangle(signal) {
if (collected_effects === null) {
// too late — the batch's UI is (at least partially) committed already.
// Readers fall back to observing the latest value, and are re-run when
// the owner commits (if the value they saw turns out to be stale)
return false;
}
var batch = /** @type {Batch} */ (active_batch).resolved();
batch.claim(signal);
// claiming may not have merged the batches (e.g. the owner is sealed, and we
// are now waiting behind it instead) — the recomputed overlay tells us
// whether the signal still belongs to another world
var override = batch.values?.get(signal);
return override === undefined || override[1] === null;
}
/**
* (Re)connect a disconnected derived, so that it is notified
* of changes in `mark_reactions`

@ -10,22 +10,23 @@ export default test({
y.click();
await tick();
// the new branch's reactive reads of `x` are new dependencies on a value the
// pending batch has written, so they see the latest value ('universe')
// the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
universe
`
);
resolve.click();
await tick();
// the init-time console.log runs outside a reaction and sees the latest value;
// the second child's $effect already saw the committed value, so it does not re-run
// both branches commit together, fully consistent. The init-time
// console.logs ran eagerly (with the latest value), the $effects
// were deferred until the commit
assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
assert.htmlEqual(
target.innerHTML,

@ -10,8 +10,9 @@ export default test({
y.click();
await tick();
// the new branch's reads of `x` are new dependencies on a value the
// pending batch has written, so they see the latest value ('universe')
// the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes
assert.htmlEqual(
target.innerHTML,
`
@ -19,17 +20,12 @@ export default test({
<button>y++</button>
<button>resolve</button>
<hr>
universe
"universe"
universe
universe
universe
"universe"
`
);
resolve.click();
await tick();
// both branches commit together, fully consistent
assert.htmlEqual(
target.innerHTML,
`

@ -22,8 +22,10 @@ export default test({
resolve.click();
await tick();
// the new branch's async expression read `x` as a new dependency on a value
// the pending batch had written, so it saw the latest value ('universe')
// the new branch's async expression read `x`, which the pending batch had
// written, as a new dependency — the two batches entangled, so even though
// the new branch's own async work has completed, it is held back until the
// first branch's async work completes too
assert.htmlEqual(
target.innerHTML,
`
@ -31,19 +33,12 @@ export default test({
<button>y++</button>
<button>resolve</button>
<hr>
universe
"universe"
universe
universe
universe
"universe"
`
);
resolve.click();
await tick();
resolve.click();
await tick();
// both branches commit together, fully consistent
assert.htmlEqual(
target.innerHTML,
`

@ -11,23 +11,23 @@ export default test({
y.click();
await tick();
// the new branch's reads of `x` are new dependencies on a value the
// pending batch has written, so they see the latest value ('universe') —
// including the init-time console.log, which runs outside a reaction
assert.deepEqual(logs, ['universe', 'universe', '$effect: universe']);
// the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes. Its $effect is deferred, but the
// init-time console.log necessarily runs eagerly (with the latest value)
assert.deepEqual(logs, ['universe', 'universe']);
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
universe
`
);
resolve.click();
await tick();
// the second child's $effect already saw the committed value, so it does not re-run
// both branches commit together, fully consistent
assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
assert.htmlEqual(
target.innerHTML,

@ -0,0 +1,51 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [x, y, resolve] = target.querySelectorAll('button');
x.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
<h1>WORLD</h1>
`
);
y.click();
await tick();
// the new branch reads `upper` — a derived owned by the pending batch — as
// a new dependency. The two batches entangle, so the new branch is held
// back until the async work completes (rather than rendering with the
// derived's pre-write value, 'WORLD')
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
<h1>WORLD</h1>
`
);
resolve.click();
await tick();
// both branches commit together, fully consistent
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
<h1>UNIVERSE</h1>
universe
<p>UNIVERSE</p>
`
);
}
});

@ -0,0 +1,29 @@
<script>
let x = $state({ x: 'world' });
let y = $state(0);
let deferred = [];
const upper = $derived(x.x.toUpperCase());
function delay(s) {
const d = Promise.withResolvers();
deferred.push(() => d.resolve(s));
return d.promise;
}
</script>
<button onclick={() => (x = { x: 'universe' })}>x</button>
<button onclick={() => y++}>y++</button>
<button onclick={() => deferred.shift()()}>resolve</button>
<h1>{upper}</h1>
{#if x.x === 'universe'}
{await delay(x.x)}
{/if}
{#if y > 0}
<p>{upper}</p>
{/if}

@ -11,23 +11,23 @@ export default test({
y.click();
await tick();
// the new branch's reads of `x` are new dependencies on a value the
// pending batch has written, so they see the latest value ('universe') —
// including the init-time console.log, which runs outside a reaction
assert.deepEqual(logs, ['universe', 'universe', '$effect: universe']);
// the new branch reads `x`, which the pending batch has written, as a new
// dependency — the two batches entangle, so the new branch is held back
// until the async work completes. Its $effect is deferred, but the
// init-time console.log necessarily runs eagerly (with the latest value)
assert.deepEqual(logs, ['universe', 'universe']);
assert.htmlEqual(
target.innerHTML,
`
<button>x</button>
<button>y++</button>
<button>resolve</button>
universe
`
);
resolve.click();
await tick();
// the second child's $effect already saw the committed value, so it does not re-run
// both branches commit together, fully consistent
assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']);
assert.htmlEqual(
target.innerHTML,

Loading…
Cancel
Save