refactor: entangle overlapping async batches

entangle-batches-2
Simon Holthausen 2 months 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! * should not be used for any other purpose!
*/ */
export const WAS_MARKED = 1 << 16; 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 // Flags used for async
export const REACTION_IS_UPDATING = 1 << 21; export const REACTION_IS_UPDATING = 1 << 21;

@ -1,5 +1,5 @@
/** @import { Blocker, Effect, Source, Value } from '#client' */ /** @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 { DEV } from 'esm-env';
import { import {
component_context, component_context,
@ -39,7 +39,13 @@ export function flatten(blockers, sync, async, fn) {
// Filter out already-settled blockers - no need to wait for them // Filter out already-settled blockers - no need to wait for them
var pending = blockers.filter((b) => !b.settled); 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) { if (DEV) {
deriveds.forEach((d, i) => { 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 { equals, safe_equals } from './equality.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import * as w from '../warnings.js'; import * as w from '../warnings.js';
import { import { async_effect, destroy_effect, destroy_effect_children, teardown } from './effects.js';
async_effect,
destroy_effect,
destroy_effect_children,
effect_tracking,
teardown
} from './effects.js';
import { eager_effects, internal_set, set_eager_effects, source } from './sources.js'; import { eager_effects, internal_set, set_eager_effects, source } from './sources.js';
import { get_error } from '../../shared/dev.js'; import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
@ -90,7 +84,8 @@ export function derived(fn) {
v: /** @type {V} */ (UNINITIALIZED), v: /** @type {V} */ (UNINITIALIZED),
wv: 0, wv: 0,
parent: active_effect, parent: active_effect,
ac: null ac: null,
batch: null
}; };
if (DEV && tracing_mode_flag) { if (DEV && tracing_mode_flag) {
@ -391,35 +386,48 @@ export function execute_derived(derived) {
*/ */
export function update_derived(derived) { export function update_derived(derived) {
var value = execute_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)) { if (!derived.equals(value)) {
derived.wv = increment_write_version(); derived.wv = increment_write_version();
// in a fork, we don't update the underlying value, just `batch_values`. // note the previous value, so that other (non-overlapping) batches can
// the underlying value will be updated when the fork is committed. // keep operating against the pre-write world until this one commits
// otherwise, the next time we get here after a 'real world' state if (batch !== null && !batch.is_fork) {
// change, `derived.equals` may incorrectly return `true` batch.record_previous(derived);
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;
} }
derived.v = value;
// deriveds without dependencies should never be recomputed // deriveds without dependencies should never be recomputed
if (derived.deps === null) { if (derived.deps === null) {
set_signal_status(derived, CLEAN); set_signal_status(derived, CLEAN);
return; return;
} }
} }
}
// don't mark derived clean if we're reading it inside a // don't mark derived clean if we're reading it inside a
// cleanup function, or it will cache a stale value // cleanup function, or it will cache a stale value
@ -427,18 +435,8 @@ export function update_derived(derived) {
return; 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);
} }
}
/** /**
* @param {Derived} derived * @param {Derived} derived

@ -112,14 +112,17 @@ function create_effect(type, fn) {
prev: null, prev: null,
teardown: null, teardown: null,
wv: 0, wv: 0,
ac: null ac: null,
batch: null
}; };
if (DEV) { if (DEV) {
effect.component_function = dev_current_component_function; 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} */ /** @type {Effect | null} */
var e = effect; var e = effect;

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

@ -7,6 +7,7 @@ import type {
TransitionManager TransitionManager
} from '#client'; } from '#client';
import type { Boundary } from '../dom/blocks/boundary'; import type { Boundary } from '../dom/blocks/boundary';
import type { Batch } from './batch.js';
export interface Signal { export interface Signal {
/** Flags bitmask */ /** Flags bitmask */
@ -50,6 +51,13 @@ export interface Reaction extends Signal {
deps: null | Value[]; deps: null | Value[];
/** An AbortController that aborts when the signal is destroyed */ /** An AbortController that aborts when the signal is destroyed */
ac: null | AbortController; 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 { export interface Derived<V = unknown> extends Value<V>, Reaction {

@ -23,7 +23,8 @@ import {
ERROR_VALUE, ERROR_VALUE,
WAS_MARKED, WAS_MARKED,
MANAGED_EFFECT, MANAGED_EFFECT,
REACTION_RAN REACTION_RAN,
TEMPLATE_EXPRESSION
} from './constants.js'; } from './constants.js';
import { old_values } from './reactivity/sources.js'; import { old_values } from './reactivity/sources.js';
import { import {
@ -49,6 +50,7 @@ import {
import { import {
Batch, Batch,
batch_values, batch_values,
claimed_by_other,
current_batch, current_batch,
flushSync, flushSync,
previous_batch, previous_batch,
@ -168,6 +170,27 @@ export function is_dirty(reaction) {
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
var dependency = dependencies[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))) { if (is_dirty(/** @type {Derived} */ (dependency))) {
update_derived(/** @type {Derived} */ (dependency)); update_derived(/** @type {Derived} */ (dependency));
} }
@ -177,12 +200,7 @@ export function is_dirty(reaction) {
} }
} }
if ( if ((flags & CONNECTED) !== 0) {
(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
) {
set_signal_status(reaction, CLEAN); set_signal_status(reaction, CLEAN);
} }
} }
@ -659,6 +677,29 @@ export function get(signal) {
return value; return value;
} }
// 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 (
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, // connect disconnected deriveds if we are reading them inside an effect,
// or inside another derived that is already connected // or inside another derived that is already connected
var should_connect = var should_connect =
@ -684,9 +725,23 @@ export function get(signal) {
reconnect(derived); reconnect(derived);
} }
} }
}
if (batch_values?.has(signal)) { if (batch_values !== null) {
return batch_values.get(signal); 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) { if ((signal.f & ERROR_VALUE) !== 0) {

@ -13,9 +13,12 @@ export default test({
await tick(); await tick();
increment.click(); increment.click();
await tick(); 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(); await tick();
assert.htmlEqual(div.innerHTML, '2 2'); assert.htmlEqual(div.innerHTML, '0 0 0');
shift.click(); shift.click();
await tick(); await tick();

@ -25,9 +25,12 @@ export default test({
assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>0</p>`); assert.htmlEqual(target.innerHTML, `<button>shift</button><input type="number" /> <p>0</p>`);
assert.equal(input.value, '2'); 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(); shift.click();
await tick(); 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'); assert.equal(input.value, '2');
shift.click(); shift.click();

@ -47,6 +47,9 @@ export default test({
); );
assert.equal(select.value, 'one'); 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(); shift.click();
await tick(); await tick();
assert.htmlEqual( assert.htmlEqual(
@ -58,7 +61,7 @@ export default test({
<option>two</option> <option>two</option>
<option>three</option> <option>three</option>
</select> </select>
<p>three</p> <p>two</p>
` `
); );
assert.equal(select.value, 'one'); assert.equal(select.value, 'one');

@ -26,6 +26,9 @@ export default test({
flushSync(() => button2.click()); flushSync(() => button2.click());
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(); button1.click();
await tick(); await tick();
@ -34,8 +37,8 @@ export default test({
` `
<button>shift</button> <button>shift</button>
<button>+</button> <button>+</button>
<p>AA</p> <p>A</p>
<p>aa</p> <p>a</p>
` `
); );

@ -21,19 +21,19 @@ export default test({
await tick(); await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>a</p><p>aa</p><p>1</p>`); 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 // the two batches share the awaited `a + b` expression, so they were
// still-pending `a` batch, whose async work has not completed — `a` must // merged into one, superseding the first in-flight run. Resolving the
// still read 'a', and the unrelated `c` update must not be blocked // re-run ('bb', which pop reaches first) commits the combined state,
// including the `c` write from the $effect during the flush phase
pop.click(); pop.click();
await tick(); 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(); shift.click();
await tick(); 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(); shift.click();
await tick(); await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>b</p><p>b</p><p>bb</p><p>2</p>`); 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()); 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) { for (let i = 1; i < 5; i += 1) {
shift.click(); shift.click();
await tick(); 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(); shift.click();
await tick(); await tick();
assert.htmlEqual( assert.htmlEqual(
@ -31,7 +34,6 @@ export default test({
<button>add</button> <button>add</button>
<button>shift</button> <button>shift</button>
<p>1</p> <p>1</p>
<p>2</p>
` `
); );
@ -43,8 +45,6 @@ export default test({
<button>add</button> <button>add</button>
<button>shift</button> <button>shift</button>
<p>1</p> <p>1</p>
<p>2</p>
<p>3</p>
` `
); );

@ -23,11 +23,14 @@ export default test({
f.click(); f.click();
await tick(); 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(); shift.click();
await tick(); await tick();
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, 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(); shift.click();

@ -13,11 +13,14 @@ export default test({
'<button>a_b 0_0</button> <button>b 0</button> <button>resolve</button> 0' '<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(); b.click();
await tick(); await tick();
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, 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(); resolve.click();

@ -15,11 +15,15 @@ export default test({
flushSync(() => a.click()); flushSync(() => a.click());
flushSync(() => b.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(); pop.click();
await tick(); await tick();
assert.htmlEqual(p.innerHTML, '1 + 3 = 4'); assert.htmlEqual(p.innerHTML, '2 + 3 = 5');
// resolving the superseded run does nothing
pop.click(); pop.click();
await tick(); await tick();

@ -67,7 +67,7 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, 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 b</button>
<button>a and c</button> <button>a and c</button>
<button>b and d</button> <button>b and d</button>
@ -81,7 +81,7 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, 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 b</button>
<button>a and c</button> <button>a and c</button>
<button>b and d</button> <button>b and d</button>

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

@ -52,7 +52,7 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
a 1 | b 2 | c 0 | d 2 a 1 | b 2 | c 1 | d 3
<button>a++</button> <button>a++</button>
<button>c++</button> <button>c++</button>
<button>shift</button> <button>shift</button>
@ -65,7 +65,7 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
a 1 | b 2 | c 0 | d 2 a 1 | b 2 | c 1 | d 3
<button>a++</button> <button>a++</button>
<button>c++</button> <button>c++</button>
<button>shift</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(); shift.click();
await tick(); await tick();
shift.click(); shift.click();
@ -40,14 +43,12 @@ export default test({
<button>add</button> <button>add</button>
<button>shift</button> <button>shift</button>
<button>pop</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>not keyed:
<div>1</div> <div>1</div>
<div>2</div>
</div> </div>
<div>keyed: <div>keyed:
<div>1</div> <div>1</div>
<div>2</div>
</div> </div>
` `
); );
@ -62,16 +63,12 @@ export default test({
<button>add</button> <button>add</button>
<button>shift</button> <button>shift</button>
<button>pop</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>not keyed:
<div>1</div> <div>1</div>
<div>2</div>
<div>3</div>
</div> </div>
<div>keyed: <div>keyed:
<div>1</div> <div>1</div>
<div>2</div>
<div>3</div>
</div> </div>
` `
); );

@ -21,9 +21,12 @@ export default test({
await tick(); await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); 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(); pop.click();
await tick(); await tick();
assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
shift.click(); shift.click();
await tick(); await tick();

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

@ -21,13 +21,16 @@ export default test({
await tick(); await tick();
assert.htmlEqual(target.innerHTML, `<button>3</button><button>shift</button><p>0</p>`); 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(); shift.click();
await tick(); 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(); shift.click();
await tick(); 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(); shift.click();
await tick(); 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(); resolve.click();
await tick(); await tick();
assert.htmlEqual( assert.htmlEqual(
@ -44,12 +48,6 @@ export default test({
<button>resolve</button> <button>resolve</button>
<button>commit</button> <button>commit</button>
<hr> <hr>
world
"world"
world
world
world
"world"
` `
); );

Loading…
Cancel
Save