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

@ -302,6 +302,15 @@ export class Batch {
*/
is_eager = false;
/**
* `true` once this batch has committed (some of) its UI from that point on
* it can no longer entangle with other pending batches on new reads, because
* what is on screen was rendered with this batch's own world (a batch can
* stay live after committing, e.g. while a boundary shows its pending
* snippet until the async work inside it settles)
*/
committed = false;
/**
* If this batch was merged into another one (because their reactivity graphs
* turned out to overlap), this points to the batch it was merged into. Stale
@ -732,6 +741,7 @@ export class Batch {
}
this.restarts = Math.max(this.restarts, other.restarts);
this.committed ||= other.committed;
// `other`'s settled() promise resolves when this batch settles
if (other.#deferred !== null) {
@ -922,6 +932,10 @@ export class Batch {
this.#dirty_effects = null;
this.#maybe_dirty_effects = null;
// this batch's UI is about to hit the DOM — new reads can no longer
// entangle it with other pending batches
this.committed = true;
// append/remove branches
if (this.#commit_callbacks !== null) {
for (const fn of this.#commit_callbacks) fn(this);

@ -572,6 +572,14 @@ export function get(signal) {
var flags = signal.f;
var is_derived = (flags & DERIVED) !== 0;
/**
* Whether a read outside the init/update cycle (i.e. after an `await`) added
* `signal` to the reaction's deps for the first time. During the init/update
* cycle this stays `false` first-time reads are detected by checking
* `deps` instead (new deps accumulate in `new_deps` in that case)
*/
var first_read = false;
captured_signals?.add(signal);
// Register the dependency on the current reaction signal.
@ -609,6 +617,7 @@ export function get(signal) {
active_reaction.deps ??= [];
if (!includes.call(active_reaction.deps, signal)) {
active_reaction.deps.push(signal);
first_read = true;
}
var reactions = signal.reactions;
@ -716,7 +725,7 @@ export function get(signal) {
active_batch.values !== null &&
(owner = claimed_by_other(derived)) !== null
) {
if (is_unseen_read(derived, owner) && entangle(derived)) {
if (is_unseen_read(derived, owner, first_read) && 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)
@ -795,9 +804,10 @@ export function get(signal) {
var override_value = override[0];
if (
(active_reaction.f & REACTION_IS_UPDATING) !== 0 &&
(signal.f & DERIVED) === 0 &&
(active_reaction.deps === null || !includes.call(active_reaction.deps, signal))
((active_reaction.f & REACTION_IS_UPDATING) !== 0
? active_reaction.deps === null || !includes.call(active_reaction.deps, signal)
: first_read)
) {
// the reaction never depended on this signal before the owner's write —
// the pre-write world never contained this combination of values.
@ -849,13 +859,18 @@ export function get(signal) {
* a reaction never have history.)
* @param {Value} signal
* @param {Batch} owner
* @param {boolean} first_read whether a post-`await` read just added `signal` to the reaction's deps
* @returns {boolean}
*/
function is_unseen_read(signal, owner) {
function is_unseen_read(signal, owner, first_read) {
if (active_reaction === null) return true;
if (untracking || (active_reaction.f & REACTION_IS_UPDATING) === 0) return false;
if (untracking) return false;
if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) {
if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) {
if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) {
return false;
}
} else if (!first_read) {
return false;
}
@ -866,21 +881,22 @@ function is_unseen_read(signal, owner) {
/**
* 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.
* while none of the batch's UI has been committed during effect tree
* traversal, or in an async continuation of a still-pending batch. 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) {
var batch = /** @type {Batch} */ (active_batch).resolved();
if (collected_effects === null && batch.committed) {
// 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

@ -0,0 +1,63 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [a, t, shift_a, shift_t] = target.querySelectorAll('button');
shift_a.click();
shift_t.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>t</button>
<button>shift a</button>
<button>shift t</button>
<p>async a: 0</p>
<p>late read: -1</p>
`
);
// batch A: writes `a`, stays pending (its promise is unresolved)
a.click();
await tick();
// batch B: writes `t`; resolve its promise so the continuation
// reads `a` for the first time while A is still pending. This
// entangles B with A — both worlds are held back and commit together
t.click();
await tick();
shift_t.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>t</button>
<button>shift a</button>
<button>shift t</button>
<p>async a: 0</p>
<p>late read: -1</p>
`
);
// commit the merged batch
shift_a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>t</button>
<button>shift a</button>
<button>shift t</button>
<p>async a: 1</p>
<p>late read: 1</p>
`
);
}
});

@ -0,0 +1,35 @@
<script>
let a = $state(0);
let t = $state(0);
let deferreds = [];
function push(key, v) {
const d = Promise.withResolvers();
deferreds.push({ key, v, d });
return d.promise;
}
function shift(key) {
const i = deferreds.findIndex((d) => d.key === key);
if (i === -1) return;
const [{ v, d }] = deferreds.splice(i, 1);
d.resolve(v);
}
</script>
<button onclick={() => a++}>a</button>
<button onclick={() => t++}>t</button>
<button onclick={() => shift('a')}>shift a</button>
<button onclick={() => shift('t')}>shift t</button>
<svelte:boundary>
<p>async a: {await push('a', a)}</p>
<!-- reads `a` after an await, but only once t > 0 -->
<p>late read: {(await push('t', t), t > 0 ? a : -1)}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>
Loading…
Cancel
Save