refactor: entangle overlapping async batches

entangle-batches-2
Simon Holthausen 2 weeks ago
parent a3b5db6fe9
commit 89bf752b9c
No known key found for this signature in database

@ -0,0 +1,5 @@
---
'svelte': minor
---
refactor: entangle overlapping async batches instead of time-travelling their shared reactivity graph

@ -52,6 +52,13 @@ 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;

@ -1,5 +1,5 @@
/** @import { Blocker, Effect, Source, Value } from '#client' */
import { DESTROYED, STALE_REACTION } from '#client/constants';
import { DESTROYED, STALE_REACTION, TEMPLATE_EXPRESSION } from '#client/constants';
import { DEV } from 'esm-env';
import {
component_context,
@ -39,7 +39,13 @@ 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(d);
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;
});
if (DEV) {
deriveds.forEach((d, i) => {

File diff suppressed because it is too large Load Diff

@ -31,13 +31,7 @@ import {
import { equals, safe_equals } from './equality.js';
import * as e from '../errors.js';
import * as w from '../warnings.js';
import {
async_effect,
destroy_effect,
destroy_effect_children,
effect_tracking,
teardown
} from './effects.js';
import { async_effect, destroy_effect, destroy_effect_children, teardown } from './effects.js';
import { eager_effects, internal_set, set_eager_effects, source } from './sources.js';
import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
@ -90,7 +84,8 @@ export function derived(fn) {
v: /** @type {V} */ (UNINITIALIZED),
wv: 0,
parent: active_effect,
ac: null
ac: null,
batch: null
};
if (DEV && tracing_mode_flag) {
@ -391,33 +386,46 @@ export function execute_derived(derived) {
*/
export function update_derived(derived) {
var value = execute_derived(derived);
var batch = current_batch ?? previous_batch;
var fork_values = batch !== null && batch.is_fork ? batch.fork_values : null;
if (fork_values !== null && derived.deps !== null) {
// Inside a fork, neither the underlying value nor the status are
// updated — the fork's view of the derived lives in the fork's own
// overlay, and is simply dropped if the fork is discarded, while the
// real status keeps describing the real (untouched) value. (Deriveds
// without dependencies never recompute, so they are treated like the
// real world below.)
var previous = fork_values.has(derived) ? fork_values.get(derived) : derived.v;
if (
previous === UNINITIALIZED ||
!derived.equals.call(/** @type {any} */ ({ v: previous }), value)
) {
derived.wv = increment_write_version();
}
fork_values.set(derived, value);
batch_values?.set(derived, [value, null]);
return;
}
if (!derived.equals(value)) {
derived.wv = increment_write_version();
// in a fork, we don't update the underlying value, just `batch_values`.
// the underlying value will be updated when the fork is committed.
// otherwise, the next time we get here after a 'real world' state
// change, `derived.equals` may incorrectly return `true`
if (!current_batch?.is_fork || derived.deps === null) {
if (current_batch !== null) {
// We also write to previous_batch because if it exists, it is a sign that we're
// currently in the process of flushing effects. These updates to deriveds may belong
// to the previous batch, not the new one (which can already exist if an earlier
// effect wrote to a source). This can cause bugs when running batch.#commit() later,
// but not adding it to current_batch can, too, so we add it to both.
// See https://github.com/sveltejs/svelte/pull/18117 for more details.
current_batch.capture(derived, value, true);
previous_batch?.capture(derived, value, true);
} else {
derived.v = value;
}
// note the previous value, so that other (non-overlapping) batches can
// keep operating against the pre-write world until this one commits
if (batch !== null && !batch.is_fork) {
batch.record_previous(derived);
}
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {
set_signal_status(derived, CLEAN);
return;
}
derived.v = value;
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {
set_signal_status(derived, CLEAN);
return;
}
}
@ -427,17 +435,7 @@ export function update_derived(derived) {
return;
}
// During time traveling we don't want to reset the status so that
// traversal of the graph in the other batches still happens
if (batch_values !== null) {
// only cache the value if we're in a tracking context, otherwise we won't
// clear the cache in `mark_reactions` when dependencies are updated
if (effect_tracking() || current_batch?.is_fork) {
batch_values.set(derived, value);
}
} else {
update_derived_status(derived);
}
update_derived_status(derived);
}
/**

@ -112,14 +112,17 @@ function create_effect(type, fn) {
prev: null,
teardown: null,
wv: 0,
ac: null
ac: null,
batch: null
};
if (DEV) {
effect.component_function = dev_current_component_function;
}
current_batch?.register_created_effect(effect);
// effects created while a batch is active belong to that batch's world
// (claiming is a no-op for template-level effects and inside forks)
current_batch?.claim(effect);
/** @type {Effect | null} */
var e = effect;

@ -38,6 +38,7 @@ import { component_context, is_runes } from '../context.js';
import {
Batch,
batch_values,
current_batch,
eager_block_effects,
schedule_effect,
legacy_updates
@ -223,9 +224,9 @@ export function internal_set(source, value, updated_during_traversal = null) {
execute_derived(derived);
}
// During time traveling we don't want to reset the status so that
// traversal of the graph in the other batches still happens
if (batch_values === null) {
// inside a fork the real value is untouched, so the status
// (which describes the real value) must be left alone too
if (!batch.is_fork) {
update_derived_status(derived);
}
}
@ -334,7 +335,7 @@ export function increment(source) {
* @param {Effect[] | null} updated_during_traversal
* @returns {void}
*/
function mark_reactions(signal, status, updated_during_traversal) {
export function mark_reactions(signal, status, updated_during_traversal) {
var reactions = signal.reactions;
if (reactions === null) return;
@ -363,7 +364,15 @@ function mark_reactions(signal, status, updated_during_traversal) {
} else if ((flags & DERIVED) !== 0) {
var derived = /** @type {Derived} */ (reaction);
batch_values?.delete(derived);
// claiming the derived for the current batch merges any other live
// batch whose reactivity graph overlaps with ours into it
current_batch?.claim(derived);
// invalidate any world-local memoized values
if (batch_values !== null) {
batch_values.delete(derived);
current_batch?.fork_values?.delete(derived);
}
if ((flags & WAS_MARKED) === 0) {
// Only connected deriveds being executed outside the update cycle can be reliably unmarked right away

@ -7,6 +7,7 @@ import type {
TransitionManager
} from '#client';
import type { Boundary } from '../dom/blocks/boundary';
import type { Batch } from './batch.js';
export interface Signal {
/** Flags bitmask */
@ -50,6 +51,13 @@ export interface Reaction extends Signal {
deps: null | Value[];
/** An AbortController that aborts when the signal is destroyed */
ac: null | AbortController;
/**
* The batch that most recently claimed this reaction by marking it dirty.
* Only set for deriveds and user/block/async effects if two batches claim
* the same reaction, their reactivity graphs overlap and they are merged.
* A claim expires when its batch is committed or discarded (`!batch.linked`)
*/
batch: null | Batch;
}
export interface Derived<V = unknown> extends Value<V>, Reaction {

@ -23,7 +23,8 @@ import {
ERROR_VALUE,
WAS_MARKED,
MANAGED_EFFECT,
REACTION_RAN
REACTION_RAN,
TEMPLATE_EXPRESSION
} from './constants.js';
import { old_values } from './reactivity/sources.js';
import {
@ -49,6 +50,7 @@ import {
import {
Batch,
batch_values,
claimed_by_other,
current_batch,
flushSync,
previous_batch,
@ -168,6 +170,27 @@ export function is_dirty(reaction) {
for (var i = 0; i < length; i++) {
var dependency = dependencies[i];
if (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 (is_dirty(/** @type {Derived} */ (dependency))) {
update_derived(/** @type {Derived} */ (dependency));
}
@ -177,12 +200,7 @@ export function is_dirty(reaction) {
}
}
if (
(flags & CONNECTED) !== 0 &&
// During time traveling we don't want to reset the status so that
// traversal of the graph in the other batches still happens
batch_values === null
) {
if ((flags & CONNECTED) !== 0) {
set_signal_status(reaction, CLEAN);
}
}
@ -659,34 +677,71 @@ export function get(signal) {
return value;
}
// connect disconnected deriveds if we are reading them inside an effect,
// or inside another derived that is already connected
var should_connect =
(derived.f & CONNECTED) === 0 &&
!untracking &&
active_reaction !== null &&
(is_updating_effect || (active_reaction.f & CONNECTED) !== 0);
var is_new = (derived.f & REACTION_RAN) === 0;
// 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 `batch_values`
// - dirty template expression deriveds are leaves that can be shared
// between non-overlapping batches, so each world evaluates its own value
/** @type {Batch | null} */
var owner = null;
if (is_dirty(derived)) {
if (should_connect) {
// set the flag before `update_derived`, so that the derived
// is added as a reaction to its dependencies
derived.f |= CONNECTED;
if (
batch_values !== null &&
((derived.f & TEMPLATE_EXPRESSION) !== 0
? (derived.f & (DIRTY | MAYBE_DIRTY)) !== 0
: (owner = claimed_by_other(derived)) !== null)
) {
// the world-local value is memoized in `batch_values` (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 (!batch_values.has(derived)) {
batch_values.set(derived, [execute_derived(derived), owner]);
}
} else {
// connect disconnected deriveds if we are reading them inside an effect,
// or inside another derived that is already connected
var should_connect =
(derived.f & CONNECTED) === 0 &&
!untracking &&
active_reaction !== null &&
(is_updating_effect || (active_reaction.f & CONNECTED) !== 0);
var is_new = (derived.f & REACTION_RAN) === 0;
if (is_dirty(derived)) {
if (should_connect) {
// set the flag before `update_derived`, so that the derived
// is added as a reaction to its dependencies
derived.f |= CONNECTED;
}
update_derived(derived);
}
update_derived(derived);
}
if (should_connect && !is_new) {
unfreeze_derived_effects(derived);
reconnect(derived);
if (should_connect && !is_new) {
unfreeze_derived_effects(derived);
reconnect(derived);
}
}
}
if (batch_values?.has(signal)) {
return batch_values.get(signal);
if (batch_values !== null) {
var override = batch_values.get(signal);
if (override !== undefined) {
// if we're seeing another live batch's pre-write world, it must
// re-run us with the real values when it commits
var override_owner = override[1];
if (override_owner !== null && active_reaction !== null && !untracking) {
while (override_owner.merged_into !== null) override_owner = override_owner.merged_into;
override_owner.stale_readers.add(active_reaction);
}
return override[0];
}
}
if ((signal.f & ERROR_VALUE) !== 0) {

@ -13,9 +13,12 @@ export default test({
await tick();
increment.click();
await tick();
middle.click(); // resolve the second increment which will make the if block go away and the first batch discarded
// all three increments write `a` and therefore share the async work —
// they are merged into a single batch in which the first two in-flight
// runs are superseded, so resolving the second one does nothing
middle.click();
await tick();
assert.htmlEqual(div.innerHTML, '2 2');
assert.htmlEqual(div.innerHTML, '0 0 0');
shift.click();
await tick();

@ -25,9 +25,12 @@ export default test({
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>0</p>`);
assert.equal(input.value, '2');
// both edits write `count` and therefore share the async work — they are
// merged into a single batch, in which the first in-flight run is
// superseded. Only resolving the final run commits
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>1</p>`);
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>0</p>`);
assert.equal(input.value, '2');
shift.click();

@ -47,6 +47,9 @@ export default test({
);
assert.equal(select.value, 'one');
// both selections write `value` and therefore share the async work —
// they are merged into a single batch, in which the first in-flight
// run is superseded. Only resolving the final run commits
shift.click();
await tick();
assert.htmlEqual(
@ -58,7 +61,7 @@ export default test({
<option>two</option>
<option>three</option>
</select>
<p>three</p>
<p>two</p>
`
);
assert.equal(select.value, 'one');

@ -26,6 +26,9 @@ export default test({
flushSync(() => button2.click());
flushSync(() => button2.click());
// both updates write `input` and therefore share the async work — they
// are merged into a single batch, in which the first in-flight run is
// superseded. Only resolving the final run commits
button1.click();
await tick();
@ -34,8 +37,8 @@ export default test({
`
<button>shift</button>
<button>+</button>
<p>AA</p>
<p>aa</p>
<p>A</p>
<p>a</p>
`
);

@ -21,19 +21,19 @@ export default test({
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>a</p><p>aa</p><p>1</p>`);
// resolve the newer (b) batch first. Committing it must not commit the
// still-pending `a` batch, whose async work has not completed — `a` must
// still read 'a', and the unrelated `c` update must not be blocked
// the two batches share the awaited `a + b` expression, so they were
// merged into one, superseding the first in-flight run. Resolving the
// re-run ('bb', which pop reaches first) commits the combined state,
// including the `c` write from the $effect during the flush phase
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>b</p><p>ab</p><p>2</p>`);
assert.htmlEqual(target.innerHTML, `${buttons} <p>b</p><p>b</p><p>bb</p><p>2</p>`);
// stale promise from the `a` batch's first run — resolving it does nothing
// stale promises from the superseded runs — resolving them does nothing
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>b</p><p>ab</p><p>2</p>`);
assert.htmlEqual(target.innerHTML, `${buttons} <p>b</p><p>b</p><p>bb</p><p>2</p>`);
// the `a` batch's re-run await ('bb') resolves — everything is committed
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>b</p><p>b</p><p>bb</p><p>2</p>`);

@ -23,11 +23,14 @@ export default test({
flushSync(() => increment.click());
}
// all four updates write `count` and therefore share the async work —
// they are merged into a single batch, in which the first three
// in-flight runs are superseded. Only resolving the final run commits
for (let i = 1; i < 5; i += 1) {
shift.click();
await tick();
assert.equal(p.innerHTML, `${i}: ${Math.min(i, 3)}`);
assert.equal(p.innerHTML, i < 4 ? '0: 0' : '4: 3');
}
}
});

@ -23,6 +23,9 @@ export default test({
`
);
// the three updates all write `values` and therefore share the each
// block — they are merged into a single batch, which only commits
// (revealing all items at once) when all of its async work has settled
shift.click();
await tick();
assert.htmlEqual(
@ -31,7 +34,6 @@ export default test({
<button>add</button>
<button>shift</button>
<p>1</p>
<p>2</p>
`
);
@ -43,8 +45,6 @@ export default test({
<button>add</button>
<button>shift</button>
<p>1</p>
<p>2</p>
<p>3</p>
`
);

@ -23,11 +23,14 @@ export default test({
f.click();
await tick();
// all three updates write `condition` and therefore share the async
// effect — they are merged into a single batch, in which the first two
// in-flight runs are superseded. Only resolving the final run commits
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>shift</button><button>true</button><button>false</button><h1>no</h1>'
'<button>shift</button><button>true</button><button>false</button><h1>yes</h1>'
);
shift.click();

@ -13,11 +13,14 @@ export default test({
'<button>a_b 0_0</button> <button>b 0</button> <button>resolve</button> 0'
);
// `b` is only read by template leaves, so the second update doesn't
// entangle with the pending batch — it commits immediately, while the
// pending batch's `a` is still held back
b.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>a_b 0_0</button> <button>b 0</button> <button>resolve</button> 0'
'<button>a_b 0_2</button> <button>b 2</button> <button>resolve</button> 0'
);
resolve.click();

@ -15,11 +15,15 @@ export default test({
flushSync(() => a.click());
flushSync(() => b.click());
// both updates share the awaited expression, so the batches are merged —
// the first in-flight run is superseded, and resolving the latest run
// (which pop reaches first) commits the combined state
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, '1 + 3 = 4');
assert.htmlEqual(p.innerHTML, '2 + 3 = 5');
// resolving the superseded run does nothing
pop.click();
await tick();

@ -67,7 +67,7 @@ export default test({
assert.htmlEqual(
target.innerHTML,
`
a 1 | b 1 | c 0 | d 0
a 0 | b 0 | c 0 | d 0
<button>a and b</button>
<button>a and c</button>
<button>b and d</button>
@ -81,7 +81,7 @@ export default test({
assert.htmlEqual(
target.innerHTML,
`
a 2 | b 1 | c 1 | d 0
a 0 | b 0 | c 0 | d 0
<button>a and b</button>
<button>a and c</button>
<button>b and d</button>

@ -52,7 +52,7 @@ export default test({
assert.htmlEqual(
target.innerHTML,
`
a 0 | b 0 | c 1 | d 1
a 0 | b 0 | c 0 | d 0
<button>a++</button>
<button>c++</button>
<button>shift</button>

@ -52,7 +52,7 @@ export default test({
assert.htmlEqual(
target.innerHTML,
`
a 1 | b 2 | c 0 | d 2
a 1 | b 2 | c 1 | d 3
<button>a++</button>
<button>c++</button>
<button>shift</button>
@ -65,7 +65,7 @@ export default test({
assert.htmlEqual(
target.innerHTML,
`
a 1 | b 2 | c 0 | d 2
a 1 | b 2 | c 1 | d 3
<button>a++</button>
<button>c++</button>
<button>shift</button>

@ -30,6 +30,9 @@ export default test({
`
);
// the three updates all write `values` and therefore share the each
// blocks — they are merged into a single batch, which only commits
// (revealing all items at once) when all of its async work has settled
shift.click();
await tick();
shift.click();
@ -40,14 +43,12 @@ export default test({
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=4 values.length=2 values=[1,2]</p>
<p>pending=4 values.length=1 values=[1]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
</div>
`
);
@ -62,16 +63,12 @@ export default test({
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=2 values.length=3 values=[1,2,3]</p>
<p>pending=2 values.length=1 values=[1]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
</div>
`
);

@ -21,9 +21,12 @@ export default test({
await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
// the two batches share the awaited `a + b` expression, so they were
// merged into one — resolving the superseded first run does nothing,
// and the combined state commits once all async work has settled
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`);
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
shift.click();
await tick();

@ -19,11 +19,12 @@ export default test({
await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
// Check that the first batch can still resolve before the second even if one of its async values
// is already superseeded (but the subsequent batch as a whole is still pending).
// The two batches share the awaited `a + b` expression, so they were
// merged into one — resolving the superseded first run does nothing,
// and the combined state commits once all async work has settled
shift_1.click();
await tick();
assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`);
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
shift_1.click();
await tick();

@ -21,13 +21,16 @@ export default test({
await tick();
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>0</p>`);
// the three updates all write `count` and therefore share the async
// work — they are merged into a single batch, in which the first two
// in-flight runs are superseded. Only resolving the final run commits
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>1</p>`);
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>0</p>`);
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>2</p>`);
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>0</p>`);
shift.click();
await tick();

@ -34,6 +34,10 @@ export default test({
`
);
// committing the fork wrote `x`, which the pending y++ batch's async
// work depends on — the two are entangled into a single batch (whose
// async work was re-run with the committed `x`), so nothing is
// committed until all of that work has settled
resolve.click();
await tick();
assert.htmlEqual(
@ -44,12 +48,6 @@ export default test({
<button>resolve</button>
<button>commit</button>
<hr>
world
"world"
world
world
world
"world"
`
);

Loading…
Cancel
Save