batch-views
Simon Holthausen 2 days ago
parent 40bb08482f
commit d28ea11509
No known key found for this signature in database

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: keep values computed in a batch's view of the world local to that batch, and don't needlessly re-run deriveds while several batches are pending

@ -8,6 +8,7 @@ import {
resume_effect
} from '../../reactivity/effects.js';
import { HMR_ANCHOR } from '../../constants.js';
import { EFFECT_OFFSCREEN } from '#client/constants';
import { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js';
import { DEV } from 'esm-env';
@ -107,6 +108,10 @@ export class BranchManager {
// ...and append the fragment
this.anchor.before(offscreen.fragment);
onscreen = offscreen.effect;
if ((onscreen.f & EFFECT_OFFSCREEN) !== 0) {
onscreen.f ^= EFFECT_OFFSCREEN;
}
}
}
@ -194,10 +199,12 @@ export class BranchManager {
fragment.append(target);
this.#offscreen.set(key, {
effect: branch(() => fn(target)),
fragment
});
var effect = branch(() => fn(target));
// until it is committed, the branch only exists for the batches that select it
effect.f |= EFFECT_OFFSCREEN;
this.#offscreen.set(key, { effect, fragment });
} else {
this.#onscreen.set(
key,

@ -336,7 +336,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
);
if (!first_run) {
// until it is committed, the item only exists for the batches that select it
item.e.f |= EFFECT_OFFSCREEN;
if (defer) batch.unskip_effect(item.e);
}
items.set(key, item);
@ -350,10 +352,19 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
fallback = branch(() => fallback_fn(anchor));
} else {
fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text())));
// until it is committed, the fallback only exists for the batches that select it
fallback.f |= EFFECT_OFFSCREEN;
}
}
if (defer && fallback !== null && (fallback.f & EFFECT_OFFSCREEN) !== 0) {
if (length === 0) {
batch.unskip_effect(fallback);
} else {
batch.skip_effect(fallback);
}
}
if (length > keys.size) {
if (DEV) {
validate_each_keys(array, get_key);

@ -1,5 +1,5 @@
/** @import { Fork } from 'svelte' */
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
/** @import { BatchRecord, Derived, Effect, Reaction, Source, Value } from '#client' */
import {
BLOCK_EFFECT,
BRANCH_EFFECT,
@ -17,15 +17,18 @@ import {
ERROR_VALUE,
MANAGED_EFFECT,
REACTION_RAN,
DESTROYING
DESTROYING,
EFFECT_OFFSCREEN
} from '#client/constants';
import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, includes } from '../../shared/utils.js';
import { deferred, define_property } from '../../shared/utils.js';
import {
active_reaction,
get,
increment_write_version,
is_dirty,
next_local_version,
promote_record,
update_effect
} from '../runtime.js';
import * as e from '../errors.js';
@ -36,13 +39,12 @@ import { flush_eager_effects, old_values, set_eager_effects, source, update } fr
import { eager_effect, teardown, unlink_effect } from './effects.js';
import { defer_effect } from './utils.js';
import { UNINITIALIZED } from '../../../constants.js';
import { set_signal_status } from './status.js';
import { set_signal_status, update_derived_status } from './status.js';
import { invariant } from '../../shared/dev.js';
import { log_effect_tree } from '../dev/debug.js';
import { OBSOLETE } from './deriveds.js';
/** @type {Batch | null} */
let first_batch = null;
export let first_batch = null;
/** @type {Batch | null} */
let last_batch = null;
@ -59,14 +61,33 @@ export let previous_batch = null;
/**
* When time travelling (i.e. working in one batch, while other batches
* still have ongoing work), we ignore the real values of affected
* signals in favour of their values within the batch
* @type {Map<Value, any> | null}
* signals in favour of their values within the batch — along with their
* versions. It also caches deriveds evaluated in this view of the world
* (see `view_entry`), in which case `l` tells whether the value is the
* latest (real-world) value.
* `null` means that the real world (i.e. the latest values) applies
* @type {Map<Value, { v: any, wv: number, l?: boolean }> | null}
*/
export let batch_values = null;
/**
* The batch whose view of the world is active (see `apply`). Block and async
* effects that run while it is processed, and deriveds evaluated with values
* that are not the latest, are recorded in this batch
* @type {Batch | null}
*/
export let view_batch = null;
/** @type {Effect | null} */
let last_scheduled_effect = null;
/**
* Batches whose view of the world changed because another batch committed, and that
* therefore need to be processed again once the current flush is done (see `#commit`)
* @type {Set<Batch>}
*/
var rebases = new Set();
export let is_flushing_sync = false;
let is_processing = false;
@ -112,20 +133,42 @@ export class Batch {
async_deriveds = new Map();
/**
* The current values of any signals that are updated in this batch.
* Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment)
* The current values (and their versions) of any sources that are updated in this batch,
* including deriveds that were overridden via assignment.
* The keys of this map are identical to `this.previous`
* @type {Map<Value, [any, boolean]>}
* @type {Map<Value, { v: any, wv: number }>}
*/
current = new Map();
/**
* The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place.
* The values (and their versions) of any sources that are updated in this batch _before_ those updates took place.
* The keys of this map are identical to `this.current`
* @type {Map<Value, any>}
* @type {Map<Value, { v: any, wv: number }>}
*/
previous = new Map();
/**
* Results of reactions that belong to this batch rather than to the real world:
* deriveds evaluated in this batch's view of the world with values that are not the
* latest ones, and block/async effects that ran while this batch was processed.
* Effect records become global state when the batch commits (see `promote_record`)
* @type {Map<Reaction, BatchRecord>}
*/
records = new Map();
/**
* True while the batch commits, i.e. while it updates the DOM and runs effects.
* Effects that run then do so for the real world
*/
committing = false;
/**
* Earlier batches whose changes are part of this batch's view of the world, because
* they intersect with it (this batch will be merged into them, see `#merge`)
* @type {Batch[]}
*/
folded = [];
/**
* When the batch is committed (and the DOM is updated), we need to remove old branches
* and append new ones by calling the functions added inside (if/each/key/etc) blocks
@ -157,6 +200,12 @@ export class Batch {
*/
#deferred = null;
/**
* Effects that were scheduled while this batch wasn't being processed (see `schedule`)
* @type {Set<Effect>}
*/
#unprocessed = new Set();
/**
* Effects that were scheduled in this batch but not yet 'resolved' into the
* root effects that need to be flushed. Resolving — the upwards traversal that
@ -170,12 +219,6 @@ export class Batch {
*/
#scheduled = [];
/**
* Effects created while this batch was active.
* @type {Effect[]}
*/
#new_effects = [];
/**
* Deferred effects (which run after async work has completed) and their status (DIRTY or MAYBE_DIRTY)
* @type {Map<Effect, number>}
@ -216,19 +259,25 @@ export class Batch {
if (this.is_fork) return true;
for (const effect of this.#blocking_pending.keys()) {
var e = effect;
var skipped = false;
while (e.parent !== null) {
if (this.#skipped_branches.has(e)) {
skipped = true;
break;
}
e = e.parent;
// pending work inside a skipped branch doesn't block, the branch is going to be destroyed
if (!this.#is_skipped(effect)) {
return true;
}
}
if (!skipped) {
return false;
}
/**
* Whether `effect` is (inside) a branch that this batch skips, or that only exists offscreen for other batches
* @param {Effect} effect
*/
#is_skipped(effect) {
for (var e = /** @type {Effect | null} */ (effect); e !== null; e = e.parent) {
if (
this.#skipped_branches.has(e) ||
((e.f & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(e))
) {
return true;
}
}
@ -251,26 +300,52 @@ export class Batch {
* Remove an effect from the #skipped_branches map and reschedule
* any tracked dirty/maybe_dirty child effects
* @param {Effect} effect
* @param {(e: Effect) => void} callback
* @param {(e: Effect, status: number) => void} callback
*/
unskip_effect(effect, callback = (e) => this.schedule(e)) {
unskip_effect(effect, callback = (e, status) => this.schedule(e, status)) {
var tracked = this.#skipped_branches.get(effect);
if (tracked) {
this.#skipped_branches.delete(effect);
for (var e of tracked.d) {
set_signal_status(e, DIRTY);
callback(e);
callback(e, DIRTY);
}
for (e of tracked.m) {
set_signal_status(e, MAYBE_DIRTY);
callback(e);
callback(e, MAYBE_DIRTY);
}
}
if ((effect.f & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(effect)) {
// This batch adopts a branch that so far only existed offscreen (possibly for another
// batch, whose view of the world its effects ran in) — re-validate them in this batch's view
this.#revalidate_branch(effect, callback);
}
this.#unskipped_branches.add(effect);
}
/**
* @param {Effect} effect
* @param {(e: Effect, status: number) => void} callback
*/
#revalidate_branch(effect, callback) {
for (var e = effect.first; e !== null; e = e.next) {
var flags = e.f;
if ((flags & (DESTROYED | INERT)) !== 0) continue;
// branches nested inside that exist offscreen for other batches only are not adopted
if ((flags & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(e)) continue;
if ((flags & (BRANCH_EFFECT | ROOT_EFFECT | EAGER_EFFECT)) === 0 && e.fn !== null) {
callback(e, MAYBE_DIRTY);
}
this.#revalidate_branch(e, callback);
}
}
/**
* Convert the effects that were scheduled in this batch into the root effects
* that need to be traversed, marking the path to each effect (by clearing the
@ -320,7 +395,15 @@ export class Batch {
}
#process() {
if (async_mode_flag && this.started) {
// Since this batch last ran, reactions may have come to depend on its writes without it
// knowing (e.g. effects created by other batches), or have run in the view of other batches.
// Re-validate them in this batch's view — this only re-runs those that are out of date
this.#revalidate(this.current.keys());
}
this.started = true;
this.#apply_unprocessed();
if (DEV) {
// track all the values that were updated during this flush,
@ -336,7 +419,7 @@ export class Batch {
// causing the block to no longer be deferred.
for (const [e, status] of this.#dirty_reactions) {
set_signal_status(e, status);
this.schedule(e);
this.#add(e);
}
this.apply();
@ -384,7 +467,8 @@ export class Batch {
if (updates.length > 0) {
var batch = Batch.ensure();
for (const e of updates) {
batch.schedule(e);
// these were marked during traversal already (see `mark_reactions`), unless they ran since
if ((e.f & CLEAN) === 0) batch.schedule(e, (e.f & DIRTY) !== 0 ? DIRTY : MAYBE_DIRTY);
}
}
@ -421,6 +505,17 @@ export class Batch {
// clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
this.#dirty_reactions.clear();
// The results of this batch are about to become the real-world state
this.committing = true;
for (const [reaction, record] of this.records) {
if ((reaction.f & (DERIVED | DESTROYED)) === 0) {
promote_record(reaction, record);
}
}
this.records.clear();
// append/remove branches
for (const fn of this.#commit_callbacks) fn(this);
this.#commit_callbacks.clear();
@ -430,6 +525,8 @@ export class Batch {
flush_queued_effects(effects);
previous_batch = null;
this.committing = false;
this.#deferred?.resolve();
var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
@ -469,6 +566,48 @@ export class Batch {
}
}
/**
* Schedule the reactions that (transitively) depend on `sources` for re-validation in this batch.
* They are marked MAYBE_DIRTY, which means that they are only re-run if the exact versions
* of their dependencies differ from the ones they last saw (in this batch's view of the world)
* @param {Iterable<Value>} sources
*/
#revalidate(sources) {
/** @type {Set<Reaction>} */
var seen = new Set();
for (const source of sources) {
this.#mark(source, seen);
}
}
/**
* @param {Value} value
* @param {Set<Reaction>} seen
*/
#mark(value, seen) {
var reactions = value.reactions;
if (reactions === null) return;
for (const reaction of reactions) {
if (seen.has(reaction)) continue;
seen.add(reaction);
var flags = reaction.f;
if ((flags & DERIVED) !== 0) {
if ((flags & CLEAN) !== 0) set_signal_status(reaction, MAYBE_DIRTY);
this.#mark(/** @type {Derived} */ (reaction), seen);
} else if (
(flags & (EAGER_EFFECT | INERT | DESTROYED)) === 0 &&
// branches that this batch skips don't exist in its view of the world
!this.#is_skipped(/** @type {Effect} */ (reaction))
) {
this.schedule(/** @type {Effect} */ (reaction), MAYBE_DIRTY);
}
}
}
/**
* Traverse the effect tree, executing effects or stashing
* them for later execution as appropriate
@ -486,7 +625,12 @@ export class Batch {
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);
var skip =
is_skippable_branch ||
(flags & INERT) !== 0 ||
this.#skipped_branches.has(effect) ||
// branches that exist offscreen only for other batches
((flags & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(effect));
if (!skip && effect.fn !== null) {
if (is_branch) {
@ -527,8 +671,8 @@ export class Batch {
while (batch !== null) {
if (!batch.is_fork) {
// if the batches are connected, break
for (const [value, [, is_derived]] of this.current) {
if (batch.current.has(value) && !is_derived) {
for (const value of this.current.keys()) {
if (batch.current.has(value)) {
return batch;
}
}
@ -541,12 +685,18 @@ export class Batch {
}
/**
* Take over the writes of a later batch that depends on this one, because it finished first.
* Values it produced (async results, deriveds) remain valid; block effects select their
* branches again for this batch, if needed (their selections are keyed by batch)
* @param {Batch} batch
*/
#merge(batch) {
for (const [source, value] of batch.current) {
if (!this.previous.has(source) && batch.previous.has(source)) {
this.previous.set(source, batch.previous.get(source));
this.previous.set(
source,
/** @type {{ v: any, wv: number }} */ (batch.previous.get(source))
);
}
this.current.set(source, value);
@ -557,55 +707,22 @@ export class Batch {
if (d) deferred.promise.then(d.resolve).catch(d.reject);
}
for (const [reaction, record] of batch.records) {
if ((reaction.f & BLOCK_EFFECT) === 0) {
this.records.set(reaction, record);
}
}
// Clear them or else those that are still pending might get rejected on discard (after merged-into batch is done).
// This can happen when batch Y merged into X and Y has a pending boundary and therefore still-pending async deriveds inside.
batch.async_deriveds.clear();
// Mark is not guaranteed not touch these, so we transfer them
this.transfer_reactions(batch.#dirty_reactions);
/**
* mark all effects that depend on `batch.current`, except the
* async effects that we just resolved (TODO unless they depend
* on values in this batch that are NOT in the later batch?).
* Through this we also will populate the correct #skipped_branches,
* oncommit callbacks etc, so we don't need to merge them separately.
* @param {Value} value
*/
const mark = (value) => {
var reactions = value.reactions;
if (reactions === null) return;
// skip if value is derived and is neither dirty nor maybe dirty. transitive
// deriveds (a derived depending on another derived) are only MAYBE_DIRTY, so
// we must continue traversing them to reach the effects that depend on them
if ((value.f & DERIVED) !== 0 && (value.f & (DIRTY | MAYBE_DIRTY)) === 0) {
return;
}
for (const reaction of reactions) {
var flags = reaction.f;
if ((flags & DERIVED) !== 0) {
mark(/** @type {Derived} */ (reaction));
} else {
var effect = /** @type {Effect} */ (reaction);
if (flags & (ASYNC | BLOCK_EFFECT) && !this.async_deriveds.has(effect)) {
this.#dirty_reactions.delete(effect);
set_signal_status(effect, DIRTY);
this.schedule(effect);
}
}
}
};
for (const source of this.current.keys()) {
mark(source);
}
this.oncommit(() => batch.discard());
batch.#unlink();
// processing re-validates everything that depends on the (now merged) writes of this batch
current_batch = this;
this.#process();
}
@ -621,25 +738,78 @@ export class Batch {
/**
* Associate a change to a given source with the current
* batch, noting its previous and current values
* batch, noting its previous and current values (and versions).
* The source itself is only updated if this is its latest value, i.e. unless this
* is a fork (whose writes only exist in its own view of the world) or a later batch
* already wrote to it (e.g. because an async expression resolved in an earlier batch).
* @param {Value} source
* @param {any} value
* @param {boolean} [is_derived]
* @returns {boolean} whether the value is the latest one
*/
capture(source, value, is_derived = false) {
if (source.v !== UNINITIALIZED && !this.previous.has(source)) {
this.previous.set(source, source.v);
capture(source, value) {
var latest = !this.is_fork;
/** @type {Batch | null} */
var later = null;
for (var batch = this.next; batch !== null; batch = batch.next) {
if (!batch.is_fork && batch.current.has(source)) {
later = batch;
latest = false;
break;
}
}
// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`
if ((source.f & ERROR_VALUE) === 0) {
this.current.set(source, [value, is_derived]);
batch_values?.set(source, value);
if (!this.previous.has(source)) {
// the value before this batch's change is the one before the later batch's change, if any
var previous = later !== null ? later.previous.get(source) : source;
if (previous !== undefined && previous.v !== UNINITIALIZED) {
this.previous.set(source, { v: previous.v, wv: previous.wv });
}
}
if (!this.is_fork) {
var is_error = (source.f & ERROR_VALUE) !== 0;
// errors are never batch-local, or they wouldn't be thrown in `runtime.js#get`
if (is_error && !this.is_fork) latest = true;
// the same value has the same version (it can equal the real value if this
// batch sees another one, see `internal_set`) — in which case there's nothing to update
var same = !is_error && source.equals(value);
if (same) latest = false;
var entry = {
v: value,
wv: same ? source.wv : latest ? increment_write_version() : next_local_version()
};
if (!is_error) {
this.current.set(source, entry);
batch_values?.set(source, entry);
}
if (latest) {
source.v = value;
source.wv = entry.wv;
}
return latest;
}
/**
* Make the value that `source` has in this batch's view of the world this batch's own
* value, without changing anything. It then takes precedence over the values of other
* batches that this batch's view includes (see `folded`), should those change
* @param {Value} source
*/
keep(source) {
if (this.current.has(source) || (source.f & ERROR_VALUE) !== 0) return;
var entry = batch_values?.get(source) ?? { v: source.v, wv: source.wv };
this.previous.set(source, entry);
this.current.set(source, entry);
}
activate() {
@ -649,6 +819,7 @@ export class Batch {
deactivate() {
current_batch = null;
batch_values = null;
view_batch = null;
}
flush() {
@ -661,6 +832,17 @@ export class Batch {
current_batch = this;
this.#process();
for (const batch of rebases) {
rebases.delete(batch);
// the batch is processed again anyway if a flush is queued
if (batch.linked && !batch.#decrement_queued) {
old_values.clear();
current_batch = batch;
batch.#process();
}
}
} finally {
flush_count = 0;
last_scheduled_effect = null;
@ -670,6 +852,7 @@ export class Batch {
current_batch = null;
batch_values = null;
view_batch = null;
old_values.clear();
@ -693,31 +876,24 @@ export class Batch {
this.#deferred?.resolve();
}
/**
* @param {Effect} effect
*/
register_created_effect(effect) {
this.#new_effects.push(effect);
}
#commit() {
// If there are other pending batches, they now need to be 'rebased' —
// in other words, we re-run block/async effects with the newly
// committed state, unless the batch in question has a more
// recent value for a given source
// in other words, their view of the world now includes the newly committed
// state (unless the batch in question has a more recent value for a given source),
// so everything that depends on it needs to be re-validated in their view
for (let batch = first_batch; batch !== null; batch = batch.next) {
var is_earlier = batch.id < this.id;
/** @type {Source[]} */
var sources = [];
for (const [source, [value, is_derived]] of this.current) {
if (batch.current.has(source)) {
var batch_value = /** @type {[any, boolean]} */ (batch.current.get(source))[0]; // faster than destructuring
for (const [source, value] of this.current) {
var own = batch.current.get(source);
if (is_earlier && value !== batch_value) {
if (own !== undefined) {
if (is_earlier && value.v !== own.v) {
// bring the value up to date
batch.current.set(source, [value, is_derived]);
batch.current.set(source, value);
} else {
// same value or later batch has more recent value,
// no need to re-run these effects
@ -737,17 +913,19 @@ export class Batch {
}
}
var current = [...batch.current.keys()].filter(
(source) => !(/** @type {[any, boolean]} */ (batch.current.get(source))[1])
);
// If not started yet or no sources to update (which is e.g. possible for the very first batch) then bail
if (!batch.started || current.length === 0) continue;
if (!batch.started || batch.current.size === 0) continue;
var obsolete = true;
// Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds)
var others = current.filter((source) => !this.current.has(source));
for (const source of batch.current.keys()) {
if (!this.current.has(source)) {
obsolete = false;
break;
}
}
if (others.length === 0) {
if (obsolete) {
if (is_earlier) {
// this batch is now obsolete and can be discarded
batch.discard();
@ -762,65 +940,23 @@ export class Batch {
// A batch was unskipped in a later batch -> tell prior batches to unskip it, too
if (is_earlier) {
for (const unskipped of this.#unskipped_branches) {
batch.unskip_effect(unskipped, (e) => {
batch.unskip_effect(unskipped, (e, status) => {
if ((e.f & (BLOCK_EFFECT | ASYNC)) !== 0) {
batch.schedule(e);
batch.schedule(e, status);
} else {
batch.#defer_effects([e]);
batch.add_dirty_reaction(e, status);
}
});
}
}
batch.activate();
/** @type {Set<Value>} */
var marked = new Set();
/** @type {Map<Reaction, boolean>} */
var checked = new Map();
for (var source of sources) {
mark_effects(source, others, marked, checked);
}
checked = new Map();
var current_unequal = [...batch.current]
.filter(([c, v1]) => {
const v2 = this.current.get(c);
if (!v2) return true;
// Either their values are different or one is a derived but not the other
return v2[0] !== v1[0] || v2[1] !== v1[1];
})
.map(([c]) => c);
if (current_unequal.length > 0) {
for (const effect of this.#new_effects) {
if (
(effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 &&
depends_on(effect, current_unequal, checked)
) {
if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) {
set_signal_status(effect, DIRTY);
batch.schedule(effect);
} else {
batch.add_dirty_reaction(effect, DIRTY);
}
}
}
}
// Only apply and traverse when we know we triggered async work with marking the effects
// and know this won't run anyway right afterwards
if (batch.#scheduled.length > 0 && !batch.#decrement_queued) {
batch.apply();
for (var root of batch.#resolve()) {
batch.#traverse(root, [], []);
}
}
batch.deactivate();
// Everything that depends on the committed values needs to be re-validated in the
// batch's view. The batch is processed again once the current flush is done (which
// also re-validates what depends on its own writes, as the committed batch may have
// created reactions that read them), so that block effects select their branches
// and async work restarts in the new view right away
batch.#revalidate(sources);
rebases.add(batch);
}
}
}
@ -918,18 +1054,27 @@ export class Batch {
return current_batch;
}
/**
* Make this batch's view of the world the active one
*/
apply() {
if (!async_mode_flag || (!this.is_fork && this.prev === null && this.next === null)) {
if (!async_mode_flag) {
batch_values = null;
return;
}
view_batch = this;
this.folded = [];
if (!this.is_fork && this.prev === null && this.next === null) {
// this batch's view is the real world
batch_values = null;
return;
}
// if there are multiple batches, we are 'time travelling' —
// we need to override values with the ones in this batch...
batch_values = new Map();
for (const [source, [value]] of this.current) {
batch_values.set(source, value);
}
batch_values = new Map(this.current);
// ...and undo changes belonging to other batches unless they intersect
for (let batch = first_batch; batch !== null; batch = batch.next) {
@ -940,11 +1085,7 @@ export class Batch {
var intersects = false;
if (batch.id < this.id) {
for (const [source, [, is_derived]] of batch.current) {
// Derived values don't partake in the intersection mechanism, because a derived could
// be triggered in one batch already but not the other one yet, causing a false-positive
if (is_derived) continue;
for (const source of batch.current.keys()) {
if (this.current.has(source)) {
intersects = true;
break;
@ -957,9 +1098,12 @@ export class Batch {
// effects of the earlier batch in case they are merged.
// As a result you can think of batch_values as having the latest values of all intersecting
// batches up until this batch.
if (!intersects) {
if (intersects) {
this.folded.push(batch);
} else {
for (const [source, previous] of batch.previous) {
if (!batch_values.has(source)) {
// (where the views agree on the value, the real one — and its version — applies)
if (!batch_values.has(source) && !source.equals(previous.v)) {
batch_values.set(source, previous);
}
}
@ -968,12 +1112,68 @@ export class Batch {
}
/**
*
* The record of `reaction` in this batch's view of the world, i.e. that of this batch
* or of the (earlier) batches whose changes it includes, if any
* @param {Reaction} reaction
* @returns {BatchRecord | undefined}
*/
record(reaction) {
var record = this.records.get(reaction);
for (var i = this.folded.length - 1; record === undefined && i >= 0; i--) {
record = this.folded[i].records.get(reaction);
}
return record;
}
/**
* Schedule `effect` to be (re-)validated when this batch is processed. The status is only
* applied right away if this is the batch that the current flush is processing (or processes
* next, if the write happens while effects run), or if the effect is the one that is
* currently running (i.e. it invalidated itself). Otherwise it remains with
* this batch until it is processed: whether an effect is dirty depends on a batch's view of
* the world, so other batches (which may be processed first) mustn't treat it as dirty.
* The effect is then checked against the exact versions of its dependencies (i.e. it
* becomes MAYBE_DIRTY).
* @param {Effect} effect
* @param {number} status DIRTY or MAYBE_DIRTY
*/
schedule(effect) {
schedule(effect, status) {
last_scheduled_effect = effect;
if ((is_processing && current_batch === this) || effect === active_reaction) {
// don't set a DIRTY effect to MAYBE_DIRTY
if ((effect.f & DIRTY) === 0) {
set_signal_status(effect, status);
}
this.#add(effect);
} else {
this.#unprocessed.add(effect);
}
}
/**
* Apply the statuses of the effects that were scheduled while this batch wasn't being processed
*/
#apply_unprocessed() {
for (const effect of this.#unprocessed) {
// don't set a DIRTY effect to MAYBE_DIRTY
if ((effect.f & DIRTY) === 0) {
set_signal_status(effect, MAYBE_DIRTY);
}
this.#add(effect);
}
this.#unprocessed.clear();
}
/**
* @param {Effect} effect
*/
#add(effect) {
// defer render effects inside a pending boundary
// TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later
if (
@ -1009,6 +1209,7 @@ export class Batch {
}
this.linked = false;
this.records.clear();
}
}
@ -1163,37 +1364,6 @@ function flush_queued_effects(effects) {
eager_block_effects = null;
}
/**
* This is similar to `mark_reactions`, but it only marks async/block effects
* depending on `value` and at least one of the other `sources`, so that
* these effects can re-run after another batch has been committed
* @param {Value} value
* @param {Source[]} sources
* @param {Set<Value>} marked
* @param {Map<Reaction, boolean>} checked
*/
function mark_effects(value, sources, marked, checked) {
if (marked.has(value)) return;
marked.add(value);
if (value.reactions !== null) {
for (const reaction of value.reactions) {
const flags = reaction.f;
if ((flags & DERIVED) !== 0) {
mark_effects(/** @type {Derived} */ (reaction), sources, marked, checked);
} else if (
(flags & (ASYNC | BLOCK_EFFECT)) !== 0 &&
(flags & DIRTY) === 0 &&
depends_on(reaction, sources, checked)
) {
set_signal_status(reaction, DIRTY);
schedule_effect(/** @type {Effect} */ (reaction));
}
}
}
}
/**
* When committing a fork, we need to trigger eager effects so that
* any `$state.eager(...)` expressions update immediately. This
@ -1216,39 +1386,13 @@ function mark_eager_effects(value, effects) {
}
}
/**
* @param {Reaction} reaction
* @param {Source[]} sources
* @param {Map<Reaction, boolean>} checked
*/
function depends_on(reaction, sources, checked) {
const depends = checked.get(reaction);
if (depends !== undefined) return depends;
if (reaction.deps !== null) {
for (const dep of reaction.deps) {
if (includes.call(sources, dep)) {
return true;
}
if ((dep.f & DERIVED) !== 0 && depends_on(/** @type {Derived} */ (dep), sources, checked)) {
checked.set(/** @type {Derived} */ (dep), true);
return true;
}
}
}
checked.set(reaction, false);
return false;
}
/**
* @param {Effect} effect
* @param {number} status DIRTY or MAYBE_DIRTY
* @returns {void}
*/
export function schedule_effect(effect) {
/** @type {Batch} */ (current_batch).schedule(effect);
export function schedule_effect(effect, status) {
/** @type {Batch} */ (current_batch).schedule(effect, status);
}
/** @type {Source<number>[]} */
@ -1303,6 +1447,7 @@ export function eager(fn) {
var previous_batch_values = batch_values;
try {
// evaluate in the real world
batch_values = null;
value = fn();
} finally {
@ -1402,6 +1547,7 @@ export function fork(fn) {
var batch = Batch.ensure();
batch.is_fork = true;
batch_values = new Map();
view_batch = batch;
var committed = false;
var settled = batch.settled();
@ -1424,10 +1570,18 @@ export function fork(fn) {
batch.is_fork = false;
// apply changes and update write versions so deriveds see the change
for (var [source, [value]] of batch.current) {
source.v = value;
source.wv = increment_write_version();
// Apply the changes to the real world. They keep the versions they had in the fork,
// so that everything the fork computed remains valid (processing the batch
// re-validates everything that depends on them, see `#process`)
for (var [source, entry] of batch.current) {
source.v = entry.v;
source.wv = entry.wv;
if ((source.f & DERIVED) !== 0 && /** @type {BatchRecord} */ (entry).dv !== undefined) {
// an assigned derived, which holds its value until its dependencies change
promote_record(/** @type {Derived} */ (source), /** @type {BatchRecord} */ (entry));
update_derived_status(/** @type {Derived} */ (source));
}
}
// trigger any `$state.eager(...)` expressions with the new state.
@ -1451,13 +1605,6 @@ export function fork(fn) {
await settled;
},
discard: () => {
// cause any MAYBE_DIRTY deriveds to update
// if they depend on things that changed
// inside the discarded fork
for (var source of batch.current.keys()) {
source.wv = increment_write_version();
}
if (!committed && batch.linked) {
batch.discard();
}

@ -12,41 +12,41 @@ import {
DESTROYED,
CLEAN,
REACTION_RAN,
INERT
INERT,
CONNECTED
} from '#client/constants';
import {
active_reaction,
active_effect,
update_reaction,
increment_write_version,
next_local_version,
set_active_effect,
push_reaction_value,
is_destroying_effect,
update_effect,
remove_reactions,
skipped_deps,
new_deps
new_deps,
batch_record,
set_batch_record,
is_current,
promote_record
} from '../runtime.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
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';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
import { batch_values, current_batch, previous_batch } from './batch.js';
import { batch_values, current_batch, view_batch } from './batch.js';
import { increment_pending, unset_context } from './async.js';
import { deferred, includes, noop } from '../../shared/utils.js';
import { set_signal_status, update_derived_status } from './status.js';
import { update_derived_status } from './status.js';
/**
* This allows us to track 'reactivity loss' that occurs when signals
@ -81,6 +81,7 @@ export function derived(fn) {
const signal = {
ctx: component_context,
deps: null,
dv: null,
effects: null,
equals,
f: flags,
@ -218,13 +219,15 @@ export function async_derived(fn, label, location) {
if (error === OBSOLETE) return;
// write the result in the batch's view of the world
batch.activate();
batch.apply();
if (error) {
signal.f |= ERROR_VALUE;
// @ts-expect-error the error is the wrong type, but we don't care
internal_set(signal, error);
internal_set(signal, error, null, true);
} else {
if ((signal.f & ERROR_VALUE) !== 0) {
signal.f ^= ERROR_VALUE;
@ -241,7 +244,7 @@ export function async_derived(fn, label, location) {
});
}
internal_set(signal, value);
internal_set(signal, value, null, true);
}
batch.deactivate();
@ -341,6 +344,8 @@ export function execute_derived(derived) {
var prev_active_effect = active_effect;
var parent = derived.parent;
set_batch_record(null);
if (
!is_destroying_effect &&
parent !== null &&
@ -384,58 +389,132 @@ export function execute_derived(derived) {
}
/**
* Re-evaluate `derived` in the real world (i.e. with the latest values of everything)
* and update its global state
* @param {Derived} derived
* @returns {void}
*/
export function update_derived(derived) {
var record = view_batch?.records.get(derived);
if (record !== undefined && is_current(record.deps, record.dv)) {
// The active batch evaluated the derived in its view of the world, which turns out to
// be the real world now (e.g. because the batch is a fork that was committed) — adopt it
view_batch?.records.delete(derived);
promote_record(derived, record);
derived.v = record.v;
derived.wv = /** @type {number} */ (record.wv);
update_derived_status(derived);
return;
}
var value = execute_derived(derived);
// don't update the derived if we're reading it inside a
// cleanup function, or it will cache a stale value
if (is_destroying_effect) {
return;
}
if (!derived.equals(value)) {
derived.v = 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 {
update_derived_status(derived);
}
/**
* The value of `derived` as the view of the world of the active batch sees it, with its
* version, and whether it is the latest (real-world) value. The derived's global state
* describes the real world, so it is only used if it is valid within the view (i.e. the
* values it was computed from are what the view holds), and only updated if the derived is
* re-evaluated from values that are all the latest ones. Otherwise, the result is recorded
* in the batch, so that it can be reused for as long as it remains valid in its view.
* Results are cached for the lifetime of the view (see `Batch#apply`), unless the derived is
* disconnected, in which case nothing would remove them from the cache when they become stale.
* @param {Derived} derived
* @returns {{ v: any, wv: number, l?: boolean }}
*/
export function view_entry(derived) {
var values = /** @type {Map<Value, { v: any, wv: number, l?: boolean }>} */ (batch_values);
var entry = values.get(derived);
if (entry !== undefined) {
// either cached, or the derived was overridden via assignment in this batch
return entry;
}
var batch = /** @type {Batch} */ (view_batch);
var record = batch.record(derived);
if (record !== undefined && is_current(record.deps, record.dv)) {
entry = { v: record.v, wv: /** @type {number} */ (record.wv), l: false };
} else if (
(derived.f & REACTION_RAN) !== 0 &&
derived.dv !== null &&
is_current(derived.deps, derived.dv)
) {
var latest = is_latest(derived);
entry = { v: derived.v, wv: derived.wv, l: latest };
// valid in the real world, too
if (latest && (derived.f & CLEAN) === 0) update_derived_status(derived);
} else {
var value = execute_derived(derived);
if (batch_record === null) {
// everything it read is the latest value — this is the real-world value
if (!derived.equals(value)) {
derived.v = value;
derived.wv = increment_write_version();
}
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {
set_signal_status(derived, CLEAN);
return;
}
update_derived_status(derived);
entry = { v: derived.v, wv: derived.wv, l: true };
} else {
// Reuse the version of an equal value, so that reactions which saw it remain valid
var wv =
record !== undefined && derived.equals.call(/** @type {any} */ (record), value)
? /** @type {number} */ (record.wv)
: (derived.f & REACTION_RAN) !== 0 && derived.dv !== null && derived.equals(value)
? derived.wv
: next_local_version();
batch_record.v = value;
batch_record.wv = wv;
entry = { v: value, wv, l: false };
}
}
// don't mark derived clean if we're reading it inside a
// cleanup function, or it will cache a stale value
if (is_destroying_effect) {
return;
if ((derived.f & CONNECTED) !== 0) {
values.set(derived, entry);
}
// 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);
return entry;
}
/**
* Whether the values that the global state of `derived` was computed from — which are
* known to be what the active view holds — are also the latest values
* @param {Derived} derived
*/
function is_latest(derived) {
var deps = /** @type {Value[]} */ (derived.deps);
var dv = /** @type {number[]} */ (derived.dv);
var values = /** @type {Map<Value, { v: any, wv: number, l?: boolean }>} */ (batch_values);
for (var i = 0; i < dv.length; i++) {
var dep = deps[i];
if ((dep.f & DERIVED) !== 0) {
if (values.get(dep)?.l !== true) return false;
} else if (dep.wv !== dv[i]) {
return false;
}
} else {
update_derived_status(derived);
}
return true;
}
/**

@ -43,7 +43,7 @@ import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, collected_effects, current_batch } from './batch.js';
import { Batch, collected_effects } from './batch.js';
import { flatten } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
@ -103,6 +103,7 @@ function create_effect(type, fn) {
var effect = {
ctx: component_context,
deps: null,
dv: null,
nodes: null,
f: type | DIRTY | CONNECTED,
first: null,
@ -121,8 +122,6 @@ function create_effect(type, fn) {
effect.component_function = dev_current_component_function;
}
current_batch?.register_created_effect(effect);
/** @type {Effect | null} */
var e = effect;
@ -132,7 +131,7 @@ function create_effect(type, fn) {
collected_effects.push(effect);
} else {
// schedule for later
Batch.ensure().schedule(effect);
Batch.ensure().schedule(effect, DIRTY);
}
} else if (fn !== null) {
try {
@ -706,8 +705,7 @@ function resume_children(effect, local) {
// here because we don't want to eagerly recompute a derived like
// `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined
if ((effect.f & CLEAN) === 0) {
set_signal_status(effect, DIRTY);
Batch.ensure().schedule(effect); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe
Batch.ensure().schedule(effect, DIRTY); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe
}
var child = effect.first;

@ -1,4 +1,4 @@
/** @import { Derived, Effect, Source, Value } from '#client' */
/** @import { BatchRecord, Derived, Effect, Source, Value } from '#client' */
import { DEV } from 'esm-env';
import {
active_reaction,
@ -7,13 +7,14 @@ import {
get,
set_untracked_writes,
untrack,
increment_write_version,
update_effect,
current_sources,
is_dirty,
untracking,
is_destroying_effect,
push_reaction_value
push_reaction_value,
track_own_write,
version_of
} from '../runtime.js';
import { equals, safe_equals } from './equality.js';
import {
@ -25,7 +26,8 @@ import {
MAYBE_DIRTY,
BLOCK_EFFECT,
ROOT_EFFECT,
ASYNC
ASYNC,
REACTION_RAN
} from '#client/constants';
import * as e from '../errors.js';
import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';
@ -184,10 +186,17 @@ var count_deps = 0;
* @param {Value<V>} source
* @param {V} value
* @param {Effect[] | null} [updated_during_traversal]
* @param {boolean} [own_result] Whether this is a result the current batch computed (i.e. an async
* expression resolved). Such a value belongs to the batch even if it equals what the batch sees, and
* it is a change if it differs from that (even if it equals the real value)
* @returns {V}
*/
export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) {
export function internal_set(source, value, updated_during_traversal = null, own_result = false) {
var current = own_result ? batch_values?.get(source) ?? source : source;
if (own_result && source.equals.call(/** @type {Value<V>} */ (current), value)) {
Batch.ensure().keep(source);
} else if (!source.equals.call(/** @type {Value<V>} */ (current), value)) {
if (is_destroying_effect) {
old_values.set(source, value);
} else if (!old_values.has(source)) {
@ -197,7 +206,15 @@ export function internal_set(source, value, updated_during_traversal = null) {
}
var batch = Batch.ensure();
batch.capture(source, value);
if ((source.f & DERIVED) !== 0 && (source.f & (DIRTY | REACTION_RAN)) !== REACTION_RAN) {
// if we are assigning to a derived that is dirty (or was never evaluated)
// we eagerly execute it to track its dependencies
execute_derived(/** @type {Derived} */ (source));
}
var latest = batch.capture(source, value);
track_own_write(source);
if (DEV) {
if (tracing_mode_flag || active_effect !== null) {
@ -232,20 +249,21 @@ export function internal_set(source, value, updated_during_traversal = null) {
if ((source.f & DERIVED) !== 0) {
const derived = /** @type {Derived} */ (source);
// if we are assigning to a dirty derived we set it to clean/maybe dirty but we also eagerly execute it to track the dependencies
if ((source.f & DIRTY) !== 0) {
execute_derived(derived);
}
// The assigned value holds until one of the derived's dependencies changes,
// i.e. it is valid for the versions they currently have
var dv = derived.deps === null ? [] : derived.deps.map(version_of);
// 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) {
if (latest) {
derived.dv = dv;
update_derived_status(derived);
} else {
// the assignment only exists in this batch (e.g. a fork) until it is committed
var entry = /** @type {BatchRecord} */ (batch.current.get(derived));
entry.deps = derived.deps === null ? null : derived.deps.slice();
entry.dv = dv;
}
}
source.wv = increment_write_version();
// For debugging, in case you want to know which reactions are being scheduled:
// log_reactions(source);
seen = null;
@ -379,32 +397,38 @@ function mark_reactions(signal, status, updated_during_traversal) {
var not_dirty = (flags & DIRTY) === 0;
// don't set a DIRTY reaction to MAYBE_DIRTY
if (not_dirty) {
set_signal_status(reaction, status);
}
if ((flags & (EAGER_EFFECT | DERIVED)) !== 0) {
// don't set a DIRTY reaction to MAYBE_DIRTY
if (not_dirty) {
set_signal_status(reaction, status);
}
if ((flags & EAGER_EFFECT) !== 0) {
// Eager effects need to run immediately:
// - for $inspect so that the stack trace makes sense
// - for $state.eager because they might be without an effect parent
eager_effects.add(/** @type {Effect} */ (reaction));
} else if ((flags & DERIVED) !== 0) {
var derived = /** @type {Derived} */ (reaction);
if ((flags & EAGER_EFFECT) !== 0) {
// Eager effects need to run immediately:
// - for $inspect so that the stack trace makes sense
// - for $state.eager because they might be without an effect parent
eager_effects.add(/** @type {Effect} */ (reaction));
} else {
var derived = /** @type {Derived} */ (reaction);
batch_values?.delete(derived);
mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);
batch_values?.delete(derived);
mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);
}
} else if (not_dirty) {
var effect = /** @type {Effect} */ (reaction);
if ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) {
// these run right away (see `flush_queued_effects`)
set_signal_status(effect, status);
eager_block_effects.add(effect);
}
if (updated_during_traversal !== null) {
set_signal_status(effect, status);
updated_during_traversal.push(effect);
} else {
schedule_effect(effect);
// the status is applied when the batch is processed (see `Batch#schedule`)
schedule_effect(effect, status);
}
}
}

@ -46,12 +46,34 @@ export interface Reaction extends Signal {
ctx: null | ComponentContext;
/** The reaction function */
fn: null | Function;
/** Signals that this signal reads from */
/**
* Signals that this signal reads from. These are the edges of the graph, and therefore
* a superset of the dependencies of every live record of this reaction (see `dv`)
*/
deps: null | Value[];
/**
* The exact versions of the dependencies read by the last run whose result is
* global (i.e. real-world) state, aligned with the first `dv.length` entries of
* `deps`. The reaction is up to date wherever its dependencies have exactly these
* versions. `null` if no such run exists (or it was invalidated)
*/
dv: null | number[];
/** An AbortController that aborts when the signal is destroyed */
ac: null | AbortController;
}
/**
* The result of a run of a reaction that belongs to a batch rather than to the real world:
* the values it read (in order) and their exact versions at the time — and, for
* deriveds, the resulting value and its (batch-local) version
*/
export interface BatchRecord {
deps: null | Value[];
dv: number[];
v?: any;
wv?: number;
}
export interface Derived<V = unknown> extends Value<V>, Reaction {
/** The derived function */
fn: () => V;

@ -1,4 +1,4 @@
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
/** @import { BatchRecord, Derived, Effect, Reaction, Source, Value } from '#client' */
import { DEV } from 'esm-env';
import { get_descriptors, get_prototype_of, includes, index_of } from '../shared/utils.js';
import {
@ -22,7 +22,8 @@ import {
STALE_REACTION,
ERROR_VALUE,
MANAGED_EFFECT,
REACTION_RAN
REACTION_RAN,
ASYNC
} from './constants.js';
import { old_values } from './reactivity/sources.js';
import {
@ -31,7 +32,8 @@ import {
freeze_derived_effects,
recent_async_deriveds,
unfreeze_derived_effects,
update_derived
update_derived,
view_entry
} from './reactivity/deriveds.js';
import { async_mode_flag, tracing_mode_flag } from '../flags/index.js';
import { tracing_expressions } from './dev/tracing.js';
@ -49,9 +51,11 @@ import {
Batch,
batch_values,
current_batch,
first_batch,
flushSync,
previous_batch,
schedule_effect
schedule_effect,
view_batch
} from './reactivity/batch.js';
import { handle_error } from './error-handling.js';
import { UNINITIALIZED } from '../../constants.js';
@ -126,11 +130,21 @@ export function set_untracked_writes(value) {
}
/**
* @type {number} Used by sources and deriveds for handling updates.
* @type {number} Used by sources and deriveds for handling updates. Only incremented for
* real-world (i.e. latest) values — values that only exist within a batch get a
* batch-local version instead (see `next_local_version`).
* Version starts from 1 so that unowned deriveds differentiate between a created effect and a run one for tracing
**/
export let write_version = 1;
/**
* Versions of values that only exist within a batch (writes that are not the latest
* value of a source, e.g. in a fork, and deriveds computed in a batch's view of the world).
* Versions are only ever compared for identity, so these are negative to never collide
* with real-world versions.
*/
let local_version = -1;
/** @type {number} Used to version each read of a source of derived to avoid duplicating dependencies inside a reaction */
let read_version = 0;
@ -145,9 +159,100 @@ export function increment_write_version() {
return ++write_version;
}
export function next_local_version() {
return --local_version;
}
/**
* The versions of the values the currently updating reaction read, in the order they
* were first read (i.e. aligned with `deps.slice(0, skipped_deps).concat(new_deps)`)
* @type {number[]}
*/
let read_versions = [];
/**
* Whether every value the currently updating reaction read so far was its latest (real-world)
* value, rather than a value that only exists in the view of the active batch
*/
let read_latest = true;
/**
* Sources the currently updating reaction wrote to
* @type {Set<Value> | null}
*/
let own_writes = null;
/**
* Called when `source` is written to, so that the reaction that is currently updating
* (if any) isn't invalidated by its own write
* @param {Value} source
*/
export function track_own_write(source) {
if (active_reaction !== null && (active_reaction.f & REACTION_IS_UPDATING) !== 0) {
(own_writes ??= new Set()).add(source);
}
}
/**
* Set by `update_reaction` if the run produced a batch-local record rather than updating the
* reaction's global state, so that the caller can complete it (see `view_entry`)
* @type {BatchRecord | null}
*/
export let batch_record = null;
/** @param {BatchRecord | null} value */
export function set_batch_record(value) {
batch_record = value;
}
/**
* The version `signal` has in the active view of the world
* @param {Value} signal
*/
export function version_of(signal) {
if (batch_values === null) return signal.wv;
if ((signal.f & DERIVED) !== 0) {
return view_entry(/** @type {Derived} */ (signal)).wv;
}
var entry = batch_values.get(signal);
return entry === undefined ? signal.wv : entry.wv;
}
/**
* Whether the values with the given versions are (still) what `deps` hold in the active view of the world.
* Dependencies are checked in order and deriveds are brought up to date along the way, so that we
* don't evaluate a derived that the reaction would no longer read (e.g. `a ? b : c` where `a` changed)
* @param {Value[] | null} deps
* @param {number[]} dv
*/
export function is_current(deps, dv) {
for (var i = 0; i < dv.length; i++) {
var dependency = /** @type {Value[]} */ (deps)[i];
if (batch_values === null && (dependency.f & DERIVED) !== 0) {
var derived = /** @type {Derived} */ (dependency);
if (is_dirty(derived)) {
update_derived(derived);
}
}
if (version_of(dependency) !== dv[i]) {
return false;
}
}
return true;
}
/**
* Determines whether a derived or effect is dirty.
* If it is MAYBE_DIRTY, will set the status to CLEAN
* If it is MAYBE_DIRTY, will set the status to CLEAN.
* Deriveds are only checked like this in the real world — within the view of a batch,
* see `view_entry`. Effects are checked against their record in the active batch, if
* they have one (see `record_of`), or else their global state.
* @param {Reaction} reaction
* @returns {boolean}
*/
@ -159,27 +264,14 @@ export function is_dirty(reaction) {
}
if ((flags & MAYBE_DIRTY) !== 0) {
var dependencies = /** @type {Value[]} */ (reaction.deps);
var length = dependencies.length;
for (var i = 0; i < length; i++) {
var dependency = dependencies[i];
if (is_dirty(/** @type {Derived} */ (dependency))) {
update_derived(/** @type {Derived} */ (dependency));
}
var record = (flags & DERIVED) === 0 ? record_of(/** @type {Effect} */ (reaction)) : undefined;
var dv = record === undefined ? reaction.dv : record.dv;
if (dependency.wv > reaction.wv) {
return true;
}
if (dv === null || !is_current(record === undefined ? reaction.deps : record.deps, dv)) {
return true;
}
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);
}
}
@ -187,6 +279,29 @@ export function is_dirty(reaction) {
return false;
}
/**
* Block and async effects produce a result per batch (the branch a batch selected, the
* value an async expression resolved to for a batch), so each batch keeps a record of its own
* run of them until it commits. Other effects only have a single (global) state.
* @param {Effect} effect
*/
function record_of(effect) {
if (view_batch === null || (effect.f & (BLOCK_EFFECT | ASYNC)) === 0) return undefined;
return view_batch.record(effect);
}
/**
* Whether some batch holds a record of `reaction`
* @param {Reaction} reaction
*/
function has_record(reaction) {
for (var batch = first_batch; batch !== null; batch = batch.next) {
if (batch.records.size > 0 && batch.records.has(reaction)) return true;
}
return false;
}
/**
* @param {Value} signal
* @param {Effect} effect
@ -206,12 +321,7 @@ function schedule_possible_effect_self_invalidation(signal, effect, root = true)
if ((reaction.f & DERIVED) !== 0) {
schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false);
} else if (effect === reaction) {
if (root) {
set_signal_status(reaction, DIRTY);
} else if ((reaction.f & CLEAN) !== 0) {
set_signal_status(reaction, MAYBE_DIRTY);
}
schedule_effect(/** @type {Effect} */ (reaction));
schedule_effect(/** @type {Effect} */ (reaction), root ? DIRTY : MAYBE_DIRTY);
}
}
}
@ -226,6 +336,9 @@ export function update_reaction(reaction) {
var previous_component_context = component_context;
var previous_untracking = untracking;
var previous_update_version = update_version;
var previous_read_versions = read_versions;
var previous_read_latest = read_latest;
var previous_own_writes = own_writes;
var flags = reaction.f;
@ -233,6 +346,9 @@ export function update_reaction(reaction) {
skipped_deps = 0;
untracked_writes = null;
active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;
read_versions = [];
read_latest = true;
own_writes = null;
current_sources = null;
set_component_context(reaction.ctx);
@ -252,7 +368,7 @@ export function update_reaction(reaction) {
var fn = /** @type {Function} */ (reaction.fn);
var result = fn();
reaction.f |= REACTION_RAN;
var deps = update_dependencies(reaction);
var read_any = update_dependencies(reaction, (flags & REACTION_RAN) === 0);
// If we're inside an effect and we have untracked writes, then we need to
// ensure that if any of those untracked writes result in re-invalidation
@ -261,7 +377,7 @@ export function update_reaction(reaction) {
is_runes() &&
untracked_writes !== null &&
!untracking &&
deps !== null &&
read_any &&
(reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0
) {
for (var i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) {
@ -309,7 +425,7 @@ export function update_reaction(reaction) {
return result;
} catch (error) {
// still commit the deps read before the throw, otherwise deriveds connected by this run keep no reader and the reaction never re-runs when they change
update_dependencies(reaction);
update_dependencies(reaction, (flags & REACTION_RAN) === 0);
return handle_error(error);
} finally {
@ -322,28 +438,127 @@ export function update_reaction(reaction) {
set_component_context(previous_component_context);
untracking = previous_untracking;
update_version = previous_update_version;
read_versions = previous_read_versions;
read_latest = previous_read_latest;
own_writes = previous_own_writes;
}
}
/**
* Determine where the result of the current run of `reaction` belongs, record the
* versions of the values it read accordingly, and update the edges of the graph.
*
* A run belongs to the real world — and updates the reaction's global state — unless
* - it is a derived that read values which only exist in the view of the active batch
* (it is then cached in that batch, see `view_entry`)
* - it is a block or async effect that ran while the active batch was being processed
* (its result belongs to that batch until it commits, see `record_of`). The first run of
* a block effect is the exception: it creates and commits its initial branch right away
* - it is a derived evaluated in a teardown, in which case its result is discarded
*
* The edges of the graph (`reaction.deps` and the `reactions` of its dependencies) are a
* superset of the dependencies of every live record, so that changes to any of them reach
* the reaction. They are only pruned when a global run happens and no batch holds a record.
* @param {Reaction} reaction
* @param {boolean} first_run
* @returns {boolean} whether the run read anything
*/
function update_dependencies(reaction) {
function update_dependencies(reaction, first_run) {
var deps = reaction.deps;
var length = skipped_deps + (new_deps === null ? 0 : new_deps.length);
// Don't remove reactions during fork;
// they must remain for when fork is discarded
var is_fork = current_batch?.is_fork;
batch_record = null;
if (new_deps !== null) {
var i;
if (is_destroying_effect && (reaction.f & DERIVED) !== 0) {
return length > 0;
}
var dv = read_versions.length === length ? read_versions : read_versions.slice(0, length);
// anything read after this (e.g. while an error is handled) is not part of the run
read_versions = [];
if (!is_fork) {
// A reaction isn't invalidated by its own writes (unless it is explicitly marked dirty
// because of them, see `schedule_possible_effect_self_invalidation`), so sources it
// wrote to count as seen with the version they have at the end of the run
if (own_writes !== null) {
for (var i = 0; i < length; i++) {
var dep =
i < skipped_deps
? /** @type {Value[]} */ (deps)[i]
: /** @type {Value[]} */ (new_deps)[i - skipped_deps];
if (own_writes.has(dep)) {
var entry = batch_values?.get(dep);
dv[i] = entry === undefined ? dep.wv : entry.wv;
}
}
}
/** @type {Batch | null} */
var batch = null;
if ((reaction.f & DERIVED) !== 0) {
if (batch_values !== null && !read_latest) {
batch = view_batch;
}
} else if (
((reaction.f & ASYNC) !== 0 || ((reaction.f & BLOCK_EFFECT) !== 0 && !first_run)) &&
view_batch !== null &&
!view_batch.committing
) {
batch = view_batch;
}
var tracking = effect_tracking() && (reaction.f & CONNECTED) !== 0;
if (batch !== null) {
/** @type {Value[] | null} */
var read = null;
if (length > 0) {
read = deps === null ? [] : deps.slice(0, skipped_deps);
if (new_deps !== null) {
for (i = 0; i < new_deps.length; i++) {
dep = new_deps[i];
read.push(dep);
if (deps === null || !includes.call(deps, dep)) {
(reaction.deps ??= []).push(dep);
if (tracking) (dep.reactions ??= []).push(reaction);
}
}
}
}
batch.records.set(reaction, (batch_record = { deps: read, dv }));
return length > 0;
}
// a record of the reaction in the active batch would now be outdated
if (view_batch !== null && view_batch.records.size > 0) {
view_batch.records.delete(reaction);
}
var keep = deps !== null && skipped_deps < deps.length && has_record(reaction);
if (new_deps !== null) {
if (keep) {
// Records in other batches may depend on values this run didn't read — keep those edges,
// after the ones that were read, so that `dv` lines up with the start of `deps`
for (i = skipped_deps; i < /** @type {Value[]} */ (deps).length; i++) {
dep = /** @type {Value[]} */ (deps)[i];
if (!includes.call(new_deps, dep)) new_deps.push(dep);
}
} else {
remove_reactions(reaction, skipped_deps);
}
if (deps !== null && skipped_deps > 0) {
deps.length = skipped_deps + new_deps.length;
for (i = 0; i < new_deps.length; i++) {
deps[skipped_deps + i] = new_deps[i];
}
@ -351,17 +566,45 @@ function update_dependencies(reaction) {
reaction.deps = deps = new_deps;
}
if (effect_tracking() && (reaction.f & CONNECTED) !== 0) {
if (tracking) {
for (i = skipped_deps; i < deps.length; i++) {
(deps[i].reactions ??= []).push(reaction);
dep = deps[i];
var reactions = (dep.reactions ??= []);
if (!keep || !includes.call(reactions, reaction)) reactions.push(reaction);
}
}
} else if (!is_fork && deps !== null && skipped_deps < deps.length) {
} else if (deps !== null && skipped_deps < deps.length && !keep) {
remove_reactions(reaction, skipped_deps);
deps.length = skipped_deps;
}
return deps;
reaction.dv = dv;
return length > 0;
}
/**
* Make the record a batch holds of `reaction` its global state, e.g. because the batch
* committed, which makes its results the real-world ones
* @param {Reaction} reaction
* @param {BatchRecord} record
*/
export function promote_record(reaction, record) {
var deps = reaction.deps;
var read = record.deps;
if (read !== null && deps !== null && deps !== read) {
// `dv` must line up with the start of `deps`
var reordered = read.slice();
for (var dep of deps) {
if (!includes.call(read, dep)) reordered.push(dep);
}
reaction.deps = reordered;
}
reaction.dv = record.dv;
}
/**
@ -478,6 +721,9 @@ export function update_effect(effect) {
execute_effect_teardown(effect);
var teardown = update_reaction(effect);
effect.teardown = typeof teardown === 'function' ? teardown : null;
// Whether an effect is dirty is determined by the exact versions of its dependencies (see
// `update_dependencies`) — this only marks when it last ran, which `$inspect.trace` uses
effect.wv = write_version;
// In DEV, increment versions of any sources that were written to during the effect,
@ -543,6 +789,15 @@ export function get(signal) {
captured_signals?.add(signal);
/**
* The position of `signal` among the values read by the updating reaction,
* if this is the first time it reads it, so that we can record its version
*/
var index = -1;
/** The reaction that reads `signal` after an `await`, if any */
var late_reader = null;
// Register the dependency on the current reaction signal.
if (active_reaction !== null && !untracking) {
// if we're in a derived that is being read inside an _async_ derived,
@ -562,14 +817,18 @@ export function get(signal) {
// order as it did last time, increment `skipped_deps`
// rather than updating `new_deps`, which creates GC cost
if (new_deps === null && deps !== null && deps[skipped_deps] === signal) {
skipped_deps++;
index = skipped_deps++;
} else if (new_deps === null) {
index = skipped_deps;
new_deps = [signal];
} else {
index = skipped_deps + new_deps.length;
new_deps.push(signal);
}
}
} else {
late_reader = active_reaction;
// We're adding a dependency outside the init/update cycle (i.e. after an `await`).
// We have to deduplicate deps/reactions in this case or remove_reactions could
// disconnect deps/reactions that are actually still in use (if skip_deps says
@ -648,14 +907,14 @@ export function get(signal) {
}
}
if (is_destroying_effect && old_values.has(signal)) {
return old_values.get(signal);
}
if (is_derived) {
var derived = /** @type {Derived} */ (signal);
if (is_destroying_effect) {
// teardowns see the values from before the update; nothing read here is recorded
if (old_values.has(signal)) {
return old_values.get(signal);
}
if (is_destroying_effect) {
if (is_derived) {
var derived = /** @type {Derived} */ (signal);
var value = derived.v;
// if the derived is dirty and has reactions, or depends on the values that just changed, re-execute
@ -671,6 +930,22 @@ export function get(signal) {
return value;
}
}
/** The version of the value we return */
var version = signal.wv;
/** Whether the value we return is the latest (real-world) one */
var latest = true;
/**
* The value (and version) as the active view of the world sees it, if it differs from the real world
* @type {{ v: any, wv: number, l?: boolean } | undefined}
*/
var view;
if (is_derived) {
derived = /** @type {Derived} */ (signal);
// connect disconnected deriveds when reading them inside a connected reaction
var should_connect =
@ -681,13 +956,15 @@ export function get(signal) {
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;
}
if (should_connect) {
// set the flag before the derived is (re)evaluated, so that
// it is added as a reaction to its dependencies
derived.f |= CONNECTED;
}
if (batch_values !== null) {
view = view_entry(derived);
} else if (is_dirty(derived)) {
update_derived(derived);
}
@ -695,10 +972,31 @@ export function get(signal) {
unfreeze_derived_effects(derived);
reconnect(derived);
}
if (view !== undefined) {
version = view.wv;
latest = view.l === true;
} else {
version = derived.wv;
}
} else if (batch_values !== null) {
view = batch_values.get(signal);
if (view !== undefined) {
version = view.wv;
latest = version === signal.wv;
}
}
if (batch_values?.has(signal)) {
return batch_values.get(signal);
if (index !== -1) {
read_versions[index] = version;
if (!latest) read_latest = false;
} else if (late_reader !== null) {
record_late_read(late_reader, signal, version);
}
if (view !== undefined) {
return view.v;
}
if ((signal.f & ERROR_VALUE) !== 0) {
@ -708,6 +1006,43 @@ export function get(signal) {
return signal.v;
}
/**
* Record that `reaction` read `signal` (with the given version) after an `await`, i.e. after
* its run — as part of the record of that run
* @param {Reaction} reaction
* @param {Value} signal
* @param {number} version
*/
function record_late_read(reaction, signal, version) {
var record =
(reaction.f & DERIVED) === 0 ? record_of(/** @type {Effect} */ (reaction)) : undefined;
if (record !== undefined) {
if (record.deps === null) {
record.deps = [signal];
record.dv.push(version);
} else if (!includes.call(record.deps, signal)) {
record.deps.push(signal);
record.dv.push(version);
}
return;
}
var dv = reaction.dv;
if (dv === null) return;
var deps = /** @type {Value[]} */ (reaction.deps);
var i = deps.indexOf(signal);
if (i >= dv.length) {
// move it next to the other values read by the run, so that `dv` lines up with the start of `deps`
deps[i] = deps[dv.length];
deps[dv.length] = signal;
dv.push(version);
}
}
/**
* (Re)connect a disconnected derived, so that it is notified
* of changes in `mark_reactions`

@ -0,0 +1,28 @@
import { tick } from 'svelte';
import { test } from '../../test';
// An effect that several batches scheduled must run in each of them, even if another batch is flushed first
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [a, b, resolve_and_write, resolve] = target.querySelectorAll('button');
const buttons =
'<button>a</button><button>b</button><button>resolve and write</button><button>resolve</button>';
assert.htmlEqual(target.innerHTML, `${buttons}<p>0 0</p><i>0</i><span>0</span>`);
a.click();
await tick();
b.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0 0</p><i>0</i><span>0</span>`);
resolve_and_write.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0 1</p><i>0</i><span>1</span>`);
resolve.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>1 1</p><i>1</i><span>1</span>`);
}
});

@ -0,0 +1,33 @@
<script>
import Sync from './Sync.svelte';
let a = $state(0);
let b = $state(0);
let c = $state(0);
const registry = [];
function load(value) {
if (!value) return value;
return new Promise((resolve) => registry.push(() => resolve(value)));
}
</script>
<button onclick={() => a++}>a</button>
<button onclick={() => b++}>b</button>
<button
onclick={() => {
// the `<p>` values of both batches resolve into their batches (scheduling the same effect in each
// of them) after this third batch was created, but before it is flushed. The first batch stays pending
const [p_a, i_a, p_b] = registry.splice(0);
registry.push(i_a);
p_b();
p_a();
queueMicrotask(() => c++);
}}>resolve and write</button
>
<button onclick={() => registry.shift()?.()}>resolve</button>
<p>{await load(a)} {await load(b)}</p>
<i>{await load(a)}</i>
<Sync {c} />

@ -0,0 +1,6 @@
<script>
let { x } = $props();
const result = $derived(await x);
</script>
<p>block: {result}</p>

@ -0,0 +1,5 @@
<script>
let { y } = $props();
</script>
<span>{y}</span>

@ -0,0 +1,16 @@
import { tick } from 'svelte';
import { test } from '../../test';
// An effect must run in the batch that scheduled it, not in another batch that happens to be flushed first
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [button] = target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>block: 0</p><span>0</span>');
button.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>block: 1</p><span>1</span>');
}
});

@ -0,0 +1,18 @@
<script>
import Async from './Async.svelte';
import Sync from './Sync.svelte';
let x = $state(0);
let y = $state(0);
</script>
<button
onclick={() => {
x++;
// the second batch is created while the first one is pending, after the first one
// scheduled its effects but before it is flushed again
queueMicrotask(() => queueMicrotask(() => y++));
}}>increment</button
>
<Async {x} />
<Sync {y} />

@ -0,0 +1,27 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = '<button>a</button><button>b</button><button>shift</button>';
export default test({
async test({ assert, target }) {
await tick();
const [a, b, shift] = target.querySelectorAll('button');
// batch A waits on its key; batch B registers its key right away
a.click();
await tick();
b.click();
await tick();
// the older batch commits first
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>0</p><p>1</p>${buttons}`);
// the newer batch commits with its own branch
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `<p>1</p><p>B</p>${buttons}`);
}
});

@ -0,0 +1,24 @@
<script>
let a = $state(0);
let b = $state(0);
let pend = false;
const deferred = [];
function delay(value) {
if (!pend) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
const k = $derived(b === 0 ? await delay(a) : 'B');
</script>
<p>{await delay(b)}</p>
{#key k}
<p>{k}</p>
{/key}
<button onclick={() => { pend = true; a = 1; }}>a</button>
<button onclick={() => { pend = true; b = 1; }}>b</button>
<button onclick={() => deferred.shift()()}>shift</button>

@ -0,0 +1,35 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [x, y, shift] = target.querySelectorAll('button');
assert.htmlEqual(
target.innerHTML,
'<p>0</p><button>x</button><button>y</button><button>shift</button>'
);
assert.deepEqual(logs, []);
// write x — the batch is pending on its async expression
x.click();
await tick();
assert.deepEqual(logs, []);
// an independent batch runs the effect, which newly depends on `x` —
// it reads the held-back value (0) (at some point it read latest, but we decided against doing this)
y.click();
await tick();
assert.deepEqual(logs, ['effect 0 1']);
// the pending batch settles and commits x === 1, so the effect should re-run
shift.click();
await tick();
assert.deepEqual(logs, ['effect 0 1', 'effect 1 1']);
assert.htmlEqual(
target.innerHTML,
'<p>1</p><button>x</button><button>y</button><button>shift</button>'
);
}
});

@ -0,0 +1,23 @@
<script>
let x = $state(0);
let y = $state(0);
let pend = false;
const deferred = [];
function delay(value) {
if (!pend) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
$effect(() => {
if (y > 0) {
console.log(`effect ${x} ${y}`);
}
});
</script>
<p>{await delay(x)}</p>
<button onclick={() => { pend = true; x += 1; }}>x</button>
<button onclick={() => y++}>y</button>
<button onclick={() => deferred.shift()?.()}>shift</button>

@ -0,0 +1,31 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Same as `async-commit-stale-reader-changed`, but the effect reads `x` through a derived
// that the pending batch never evaluated, so only the derived's dependency was written by it
export default test({
async test({ assert, target, logs }) {
await tick();
const [x, y, shift] = target.querySelectorAll('button');
// write x — the batch is pending on its async expression
x.click();
await tick();
assert.deepEqual(logs, []);
// an independent batch runs the effect, which newly depends on `doubled` —
// it is computed from the held-back value of `x` (0)
y.click();
await tick();
assert.deepEqual(logs, ['effect 0 1']);
// the pending batch settles and commits x === 1, so the effect should re-run
shift.click();
await tick();
assert.deepEqual(logs, ['effect 0 1', 'effect 2 1']);
assert.htmlEqual(
target.innerHTML,
'<p>1</p><button>x</button><button>y</button><button>shift</button>'
);
}
});

@ -0,0 +1,24 @@
<script>
let x = $state(0);
let y = $state(0);
let pend = false;
let doubled = $derived(x * 2);
const deferred = [];
function delay(value) {
if (!pend) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
$effect(() => {
if (y > 0) {
console.log(`effect ${doubled} ${y}`);
}
});
</script>
<p>{await delay(x)}</p>
<button onclick={() => { pend = true; x += 1; }}>x</button>
<button onclick={() => y++}>y</button>
<button onclick={() => deferred.shift()?.()}>shift</button>

@ -0,0 +1,20 @@
<script>
let { revision, delay } = $props();
const assessment = $derived(await delay('assessment', revision));
// Keep the batch pending while the if blocks read parity.
const schedule = $derived(await delay('schedule', assessment));
const parity = $derived.by(() => {
console.log(['parity', assessment]);
return assessment % 2;
});
const matched = $derived.by(() => {
console.log(['matched', assessment, schedule]);
return assessment === schedule;
});
</script>
<p>{assessment}/{parity}</p>
{#if parity}<p>odd</p>{/if}
{#if schedule >= 0 && parity}<p>also odd</p>{/if}
{#if matched}<p>matched</p>{/if}
<button onclick={() => console.log(['read', parity])}>read parity</button>

@ -0,0 +1,76 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>update</button>
<button>resolve assessment 2</button>
<button>resolve schedule 2</button>
<button>resolve assessment 1</button>
`;
export default test({
mode: ['client'],
async test({ assert, target, logs }) {
await tick();
await tick();
const [update, resolve_a_2, resolve_schedule_2, resolve_a_1, read] =
target.querySelectorAll('button');
assert.htmlEqual(
target.innerHTML,
`${buttons}<p>0/0</p><p>matched</p><button>read parity</button>`
);
assert.deepEqual(logs, [
['assessment', 0],
['schedule', 0],
['parity', 0],
['matched', 0, 0]
]);
logs.length = 0;
update.click();
await tick();
update.click();
await tick();
// The dependency changes, but parity stays 0 and its write version stays
// unchanged. Both if blocks must share one evaluation in this batch's view.
resolve_a_2.click();
await tick();
assert.deepEqual(logs, [
['assessment', 1],
['assessment', 2],
['schedule', 2],
['parity', 2],
['matched', 2, 0]
]);
logs.length = 0;
// Resume the same batch and read parity again from the second if block.
// Its dependencies have not changed since its previous evaluation.
// In contrast, matched depends on schedule and must be invalidated.
resolve_schedule_2.click();
await tick();
// Committing also merges into the earlier batch and flushes the text effect,
// which must reuse the clean parity rather than evaluating it again.
assert.deepEqual(logs, [['matched', 2, 2]]);
assert.htmlEqual(
target.innerHTML,
`${buttons}<p>2/0</p><p>matched</p><button>read parity</button>`
);
logs.length = 0;
// The superseded result must not cause any further evaluations.
resolve_a_1.click();
await tick();
assert.deepEqual(logs, []);
assert.htmlEqual(
target.innerHTML,
`${buttons}<p>2/0</p><p>matched</p><button>read parity</button>`
);
// Once the competing batches are gone, parity must be globally clean.
// An event-handler read has no batch-local status to fall back on.
read.click();
assert.deepEqual(logs, [['read', 0]]);
}
});

@ -0,0 +1,25 @@
<script>
import Child from './Child.svelte';
let revision = $state(0);
const pending = new Map();
function delay(kind, value) {
console.log([kind, value]);
if (value === 0) return value;
return new Promise(resolve => pending.set(`${kind}:${value}`, () => resolve(value)));
}
function resolve(kind, value) {
pending.get(`${kind}:${value}`)?.();
}
</script>
<button onclick={() => revision++}>update</button>
<button onclick={() => resolve('assessment', 2)}>resolve assessment 2</button>
<button onclick={() => resolve('schedule', 2)}>resolve schedule 2</button>
<button onclick={() => resolve('assessment', 1)}>resolve assessment 1</button>
<svelte:boundary>
<Child {revision} {delay} />
{#snippet pending()}loading{/snippet}
</svelte:boundary>

@ -0,0 +1,44 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
await tick();
const [a, b, log, resolve] = target.querySelectorAll('button');
const [p] = target.querySelectorAll('p');
assert.deepEqual(logs, ['effect 0']);
logs.length = 0;
// batch A is pending
a.click();
await tick();
assert.htmlEqual(p.innerHTML, '0 0');
// batch B commits right away, in a view of the world without A's change
b.click();
await tick();
assert.htmlEqual(p.innerHTML, '0 -1');
assert.deepEqual(logs, ['effect -1']);
logs.length = 0;
// outside of any batch, `d` has its real value (and is only computed once
// for that, as it wasn't computed with the real values before)
log.click();
await tick();
assert.deepEqual(logs, ['real 0 3']);
logs.length = 0;
// batch A commits: `d` is 0 again, like before B — but the effect last saw -1
// (a value that only existed in B's view), so it must re-run. `d` is not re-computed
resolve.click();
await tick();
assert.htmlEqual(p.innerHTML, '1 0');
assert.deepEqual(logs, ['effect 0']);
logs.length = 0;
log.click();
await tick();
assert.deepEqual(logs, ['real 0 3']);
}
});

@ -0,0 +1,25 @@
<script>
let a = $state(0);
let b = $state(0);
let runs = 0;
// in batch B (b = -1), which doesn't see A's change (a = 1), `d` is -1 — but that
// is not the real value of `d`, which is 0 (a + b with both changes applied)
let d = $derived((runs++, a + b));
const queued = [];
function push(v) {
if (!v) return v;
return new Promise((resolve) => queued.push(() => resolve(v)));
}
$effect(() => console.log(`effect ${d}`));
</script>
<button onclick={() => a++}>a++</button>
<button onclick={() => (b = -1)}>b = -1</button>
<button onclick={() => console.log(`real ${d} ${runs}`)}>log</button>
<button onclick={() => queued.shift()?.()}>resolve</button>
<p>{await push(a)} {d}</p>

@ -0,0 +1,37 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>a</button>
<button>b</button>
<button>resolve a</button>
<button>resolve b</button>
`;
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [a, b, resolve_a, resolve_b] = target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, `${buttons}<p>0:0</p><p>0</p>`);
// sum is DIRTY and doubled is MAYBE_DIRTY, but neither runs while a is pending.
a.click();
await tick();
// The if block evaluates both deriveds with the latest inputs and marks them clean.
b.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>0:0</p><p>0</p>`);
// The earlier batch must restore doubled's MAYBE_DIRTY status as well as sum's
// DIRTY status, otherwise it reuses the cached value from before a changed.
resolve_a.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>1:2</p><p>0</p>`);
resolve_b.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>1:4</p><p>1</p><span>ready</span>`);
}
});

@ -0,0 +1,21 @@
<script>
let a = $state(0);
let b = $state(0);
const sum = $derived(a + b);
const doubled = $derived(sum * 2);
const pending = new Map();
function delay(name, value) {
if (value === 0) return value;
return new Promise(resolve => pending.set(name, () => resolve(value)));
}
</script>
<button onclick={() => a++}>a</button>
<button onclick={() => b++}>b</button>
<button onclick={() => pending.get('a')?.()}>resolve a</button>
<button onclick={() => pending.get('b')?.()}>resolve b</button>
<p>{await delay('a', a)}:{doubled}</p>
<p>{await delay('b', b)}</p>
{#if b && doubled}<span>ready</span>{/if}

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO fix
async test({ assert, target, logs }) {
await tick();

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO fix
async test({ assert, target, logs }) {
await tick();

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO fix
async test({ assert, target, logs }) {
await tick();

@ -0,0 +1,33 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Batch B (x) commits while an earlier batch A (y) is still pending, but B stays around because
// of the async work in the new boundary. While B flushes its effects, an effect creates a new batch C,
// and `d` is computed for the first time afterwards (so it's captured in both B and C). When A
// commits later, it sees B's previous values, which must be consistent (`d` must be `x * 2`).
export default test({
async test({ assert, target, logs }) {
await tick();
const [y, x, shift] = target.querySelectorAll('button');
assert.deepEqual(logs, [0, 0, 0]);
// A
y.click();
await tick();
// B
x.click();
await tick();
assert.deepEqual(logs, [0, 0, 0, 0, 1, 2]);
// A commits
shift.click();
await tick();
for (let i = 0; i < logs.length; i += 3) {
assert.equal(logs[i + 2], logs[i + 1] * 2, `torn read: ${logs.slice(i, i + 3)}`);
}
assert.deepEqual(logs.slice(-3), [1, 1, 2]);
}
});

@ -0,0 +1,38 @@
<script>
import { untrack } from 'svelte';
let x = $state(0);
let y = $state(0);
let z = $state(0);
let d = $derived(x * 2);
const queue = [];
function push(v) {
return new Promise((r) => queue.push(() => r(v)));
}
// creates a new batch while the batch that changed `x` flushes its effects...
$effect(() => {
if (x) untrack(() => z++);
});
// ...before `d` is computed for the first time in that batch
$effect(() => {
console.log(y, x, d);
});
</script>
<button onclick={() => y++}>y</button>
<button onclick={() => x++}>x</button>
<button onclick={() => queue.shift()()}>shift</button>
<p>{y ? await push(y) : y}</p>
{#if x}
<svelte:boundary>
<p>{await push(x)}</p>
{#snippet pending()}
<p>loading</p>
{/snippet}
</svelte:boundary>
{/if}

@ -0,0 +1,9 @@
<script>
let { big } = $props();
$effect(() => {
console.log(`effect ${big}`);
});
</script>
<p>child</p>

@ -0,0 +1,37 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client'],
async test({ assert, target, logs, raf }) {
await tick();
const [x, s, toggle, shift] = target.querySelectorAll('button');
assert.deepEqual(logs, ['effect false']);
// keep an earlier batch pending, so that the next one commits while time travelling
x.click();
await tick();
// `big` stays `false`, so the effect must not re-run
s.click();
await tick();
assert.deepEqual(logs, ['effect false']);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>x</button><button>s</button><button>toggle</button><button>shift</button><p>1</p><div><p>child</p></div>'
);
// outro, then reintro before the outro finished: the branch is resumed
toggle.click();
await tick();
raf.tick(50);
toggle.click();
await tick();
raf.tick(200);
assert.deepEqual(logs, ['effect false']);
}
});

@ -0,0 +1,29 @@
<script>
import { fade } from 'svelte/transition';
import Child from './Child.svelte';
let x = $state(0);
let s = $state(0);
let show = $state(true);
const big = $derived(s > 100);
const queued = [];
function push(v) {
if (v === 0) return v;
return new Promise((resolve) => queued.push(() => resolve(v)));
}
</script>
<button onclick={() => x++}>x</button>
<button onclick={() => s++}>s</button>
<button onclick={() => (show = !show)}>toggle</button>
<button onclick={() => queued.shift()?.()}>shift</button>
<p>{await push(x)}</p>
{#if show}
<div transition:fade={{ duration: 100 }}>
<Child {big} />
</div>
{/if}

@ -0,0 +1,39 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons =
'<button>b = 1</button><button>b = 2, c = 1</button><button>resolve latest pos</button><button>resolve oldest pos</button><button>resolve all</button>';
// A batch's async result equals the real value, which a later (still pending) batch wrote. The batch
// itself sees that later batch's previous value though, so the result changes its world and has to show
export default test({
mode: ['client'],
async test({ assert, target }) {
const [b1, b2c1, resolve_latest, resolve_oldest, resolve_all] =
target.querySelectorAll('button');
resolve_all.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>zero</p><span>0</span><i>c0</i>`);
b1.click();
await tick();
b2c1.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>zero</p><span>0</span><i>c0</i>`);
// the later batch's result becomes the real value, but that batch still waits for c1
resolve_latest.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>zero</p><span>0</span><i>c0</i>`);
// the earlier batch's equal result completes it
resolve_oldest.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>pos</p><span>1</span><i>c0</i>`);
resolve_all.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>pos</p><span>2</span><i>c1</i>`);
}
});

@ -0,0 +1,34 @@
<script>
let b = $state(0);
let c = $state(0);
/** @type {Map<string, Array<() => void>>} */
const pending = new Map();
/** @param {string} value */
function f(value) {
return new Promise((fulfil) => {
if (!pending.has(value)) pending.set(value, []);
pending.get(value)?.push(() => fulfil(value));
});
}
function resolve_all() {
for (const list of pending.values()) {
for (const fn of list.splice(0)) fn();
}
}
</script>
<button onclick={() => (b = 1)}>b = 1</button>
<button onclick={() => ((b = 2), (c = 1))}>b = 2, c = 1</button>
<button onclick={() => pending.get('pos')?.pop()?.()}>resolve latest pos</button>
<button onclick={() => pending.get('pos')?.shift()?.()}>resolve oldest pos</button>
<button onclick={resolve_all}>resolve all</button>
<svelte:boundary>
<p>{await f(b > 0 ? 'pos' : 'zero')}</p>
<span>{b}</span>
<i>{await f('c' + c)}</i>
{#snippet pending()}{/snippet}
</svelte:boundary>

@ -0,0 +1,47 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons =
'<button>b = 2</button><button>b = 0</button><button>resolve k3</button><button>resolve k0</button><button>resolve all</button>';
// A batch's async result that equals the current real value (but not the value the batch sees,
// because an earlier pending batch has a different one in flight) must not be dropped, or the
// earlier batch's result wins although the later batch reverted the input
export default test({
mode: ['client'],
async test({ assert, target }) {
const [b2, b0, resolve_k3, resolve_k0, resolve_all] = target.querySelectorAll('button');
resolve_all.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>k0</b><b>k1</b>`);
// batch 1 requests k2/k3
b2.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>k0</b><b>k1</b>`);
// batch 2 requests k0/k1, which equal the values currently shown
b0.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>k0</b><b>k1</b>`);
resolve_k3.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>k0</b><b>k1</b>`);
// batch 2's first result equals the real value
resolve_k0.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>k0</b><b>k1</b>`);
resolve_all.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>k0</b><b>k1</b>`);
// nothing left over
resolve_all.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>k0</b><b>k1</b>`);
}
});

@ -0,0 +1,36 @@
<script>
let b = $state(0);
/** @type {Map<string, Array<() => void>>} */
const pending = new Map();
/** @param {string} value */
function delay(value) {
return new Promise((fulfil) => {
if (!pending.has(value)) pending.set(value, []);
pending.get(value)?.push(() => fulfil(value));
});
}
/** @param {string} value */
function resolve(value) {
pending.get(value)?.shift()?.();
}
function resolve_all() {
for (const list of pending.values()) {
for (const fn of list.splice(0)) fn();
}
}
</script>
<button onclick={() => (b = 2)}>b = 2</button>
<button onclick={() => (b = 0)}>b = 0</button>
<button onclick={() => resolve('k3')}>resolve k3</button>
<button onclick={() => resolve('k0')}>resolve k0</button>
<button onclick={resolve_all}>resolve all</button>
<svelte:boundary>
<b>{await delay('k' + (0 + b))}</b><b>{await delay('k' + (1 + b))}</b>
{#snippet pending()}{/snippet}
</svelte:boundary>

@ -0,0 +1,40 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons =
'<button>b = 1</button><button>b = 2, c = 1</button><button>resolve latest pos</button><button>resolve oldest pos</button><button>resolve all</button>';
// Like `async-equal-result-dropped-later-batch`, but the result is read by something that doesn't wait
// for it: a batch's async result equals the real value, which a later (still pending) batch wrote. The
// batch itself sees that later batch's previous value though, so the result changes its world, and
// whatever read the previous value has to update
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [b1, b2c1, resolve_latest, resolve_oldest, resolve_all] =
target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, `${buttons}<p>zero</p><span>0</span><i>c0</i>`);
b1.click();
await tick();
b2c1.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>zero</p><span>0</span><i>c0</i>`);
// the later batch's result becomes the real value, but that batch still waits for c1
resolve_latest.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>zero</p><span>0</span><i>c0</i>`);
// the earlier batch's equal result completes it
resolve_oldest.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>pos</p><span>1</span><i>c0</i>`);
resolve_all.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>pos</p><span>2</span><i>c1</i>`);
}
});

@ -0,0 +1,39 @@
<script>
let b = $state(0);
let c = $state(0);
/** @type {Map<string, Array<() => void>>} */
const pending = new Map();
let initial = true;
/** @param {string} value */
function f(value) {
if (initial) return value;
return new Promise((fulfil) => {
if (!pending.has(value)) pending.set(value, []);
pending.get(value)?.push(() => fulfil(value));
});
}
$effect(() => {
initial = false;
});
function resolve_all() {
for (const list of pending.values()) {
for (const fn of list.splice(0)) fn();
}
}
let p = $derived(await f(b > 0 ? 'pos' : 'zero'));
</script>
<button onclick={() => (b = 1)}>b = 1</button>
<button onclick={() => ((b = 2), (c = 1))}>b = 2, c = 1</button>
<button onclick={() => pending.get('pos')?.pop()?.()}>resolve latest pos</button>
<button onclick={() => pending.get('pos')?.shift()?.()}>resolve oldest pos</button>
<button onclick={resolve_all}>resolve all</button>
<p>{p}</p>
{#if true}<span>{b}</span>{/if}
<i>{await f('c' + c)}</i>

@ -0,0 +1,41 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork</button>
<button>update</button>
<button>resolve</button>
<button>discard</button>
`;
export default test({
async test({ assert, target, logs }) {
const [fork_button, update, resolve, discard] = target.querySelectorAll('button');
fork_button.click();
await tick();
assert.equal(logs.length, 1);
assert.htmlEqual(target.innerHTML, buttons);
// Transfer an invalidation into the fork while its async work is pending.
update.click();
await tick();
assert.equal(logs.length, 1); // can also be 2 at this point, would also be ok
try {
resolve.click();
await tick();
assert.equal(logs.length, 1);
// Completing the replacement must not replay the same invalidation.
resolve.click();
await tick();
assert.equal(logs.length, 1);
} finally {
discard.click();
await tick();
}
assert.htmlEqual(target.innerHTML, buttons);
}
});

@ -0,0 +1,38 @@
<script>
import { fork } from 'svelte';
let sharedState = $state(0);
let show = $state(false);
let f;
let calls = 0;
const resolvers = [];
// Only evaluated in the fork, with a fresh object on each evaluation.
const searchParams = $derived({ value: sharedState });
function load(value) {
console.log(calls += 1);
return new Promise((resolve) => resolvers.push(() => resolve(value)));
}
</script>
<button
onclick={() => {
f = fork(() => {
sharedState = 1;
show = true;
});
}}
>fork</button
>
<button onclick={() => sharedState++}>update</button>
<button onclick={() => resolvers.shift()?.()}>resolve</button>
<button onclick={() => f?.discard()}>discard</button>
{#if show}
<svelte:boundary>
{#snippet pending()}loading{/snippet}
<p>{await load(searchParams.value)}</p>
</svelte:boundary>
{/if}

@ -0,0 +1,35 @@
import { flushSync, tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>increment</button>
<button>commit</button>
<button>merge</button>
<button>resolve</button>
`;
export default test({
async test({ assert, target, logs }) {
const [preload, increment, commit, merge, resolve] = target.querySelectorAll('button');
preload.click();
flushSync(() => increment.click());
assert.deepEqual(logs, [0, 1]); // [0] would also be ok; the 1 must be done in the context of the fork (impossible to assert so you gotta check manually)
commit.click();
assert.deepEqual(logs, [0, 1]);
flushSync(() => merge.click());
await tick();
assert.deepEqual(logs, [0, 1]);
assert.htmlEqual(target.innerHTML, buttons);
// Resolve the obsolete request for 0, then the existing request for 1.
resolve.click();
resolve.click();
await tick();
assert.deepEqual(logs, [0, 1]);
assert.htmlEqual(target.innerHTML, `${buttons}<p>1</p>`);
}
});

@ -0,0 +1,25 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let count = $state(0);
let gate = $state(1);
let f;
const deferred = [];
function load(value) {
console.log(value);
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<button onclick={() => (f = fork(() => (show = true)))}>preload</button>
<button onclick={() => count++}>increment</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => gate++}>merge</button>
<button onclick={() => deferred.shift()?.()}>resolve</button>
{#if show && gate > 0}
<p>{await load(count)}</p>
{/if}

@ -0,0 +1,54 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork a</button>
<button>fork b</button>
<button>reveal</button>
<button>commit a</button>
<button>commit b</button>
<button>reset</button>
`;
// A branch that was created by fork `a` must not show `a`'s values when it is
// revealed by another batch (be it a real one or another fork), and `a` must
// still show its own values when it's committed afterwards
export default test({
async test({ assert, target }) {
const [fork_a, fork_b, reveal, commit_a, commit_b, reset] = target.querySelectorAll('button');
for (const mode of ['real', 'fork']) {
try {
fork_a.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
if (mode === 'real') {
reveal.click();
} else {
fork_b.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
commit_b.click();
}
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<b>static</b><p>0</p><span>0</span>`);
commit_a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`${buttons}<b>static</b><p>1</p><span>1</span><span>2</span>`
);
reset.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
});

@ -0,0 +1,22 @@
<script>
import { fork } from 'svelte';
let show = $state(false);
let count = $state(0);
let items = $state([0]);
let a;
let b;
</script>
<button onclick={() => (a = fork(() => { show = true; count = 1; items = [1, 2]; }))}>fork a</button>
<button onclick={() => (b = fork(() => (show = true)))}>fork b</button>
<button onclick={() => (show = true)}>reveal</button>
<button onclick={() => a.commit()}>commit a</button>
<button onclick={() => b.commit()}>commit b</button>
<button onclick={() => { a?.discard(); b?.discard(); show = false; count = 0; items = [0]; }}>reset</button>
{#if show}
<svelte:boundary><b>static</b></svelte:boundary>
<p>{count}</p>
{#each items as item (item)}<span>{item}</span>{/each}
{/if}

@ -0,0 +1,53 @@
import { tick } from 'svelte';
import { test } from '../../test';
// The fallback of an each block, created by a fork, is adopted by a real batch: it has to run the
// fallback's async expression itself, since the fork's result only exists in the fork
export default test({
mode: ['client'],
async test({ assert, target, logs }) {
const [fork_empty, empty, reset, resolve_all, discard, commit] =
target.querySelectorAll('button');
const [output] = target.querySelectorAll('main');
const settle = async () => {
for (let i = 0; i < 5; i++) {
await tick();
resolve_all.click();
}
await tick();
};
for (const finish of ['discard', 'commit']) {
for (const resolved of [true, false]) {
const mode = `${finish}, ${resolved ? 'resolved' : 'pending'}`;
try {
fork_empty.click();
if (resolved) {
resolve_all.click();
await tick();
}
assert.deepEqual(logs.splice(0), ['empty']);
empty.click();
await tick();
assert.deepEqual(logs.splice(0), ['empty']);
if (finish === 'discard') discard.click();
else commit.click();
await settle();
assert.htmlEqual(output.innerHTML, '<p>empty</p>');
assert.deepEqual(logs, []);
reset.click();
await settle();
assert.htmlEqual(output.innerHTML, '<b>0</b>');
} catch (e) {
/** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`;
throw e;
}
}
}
}
});

@ -0,0 +1,25 @@
<script>
import { fork } from 'svelte';
let list = $state([0]);
const queued = [];
let current_fork;
function push(value) {
console.log(value);
return new Promise((resolve) => queued.push(() => resolve(value)));
}
function resolve_all() {
for (const r of queued.splice(0)) r();
}
</script>
<button onclick={() => (current_fork = fork(() => (list = [])))}>fork empty</button>
<button onclick={() => (list = [])}>empty</button>
<button onclick={() => (list = [0])}>reset</button>
<button onclick={resolve_all}>resolve all</button>
<button onclick={() => current_fork?.discard()}>discard</button>
<button onclick={() => current_fork?.commit()}>commit</button>
<main>{#each list as x (x)}<b>{x}</b>{:else}<p>{await push('empty')}</p>{/each}</main>

@ -0,0 +1,41 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>fork</button>
<button>resolve</button>
<button>commit</button>
`;
export default test({
async test({ assert, target, logs }) {
await tick();
const [fork_button, resolve, commit] = target.querySelectorAll('button');
assert.deepEqual(logs, [[0, true]]);
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
fork_button.click();
await tick();
assert.deepEqual(logs, [
[0, true],
[2, true]
]);
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
// `delayed` changes, but `nonnegative` stays true. The async expression has
// already consumed the fork's `doubled`, so it should not run again.
resolve.click();
await tick();
assert.deepEqual(logs, [
[0, true],
[2, true]
]);
assert.htmlEqual(target.innerHTML, `${buttons}<p>0</p>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>2</p>`);
}
});

@ -0,0 +1,27 @@
<script>
import { fork } from 'svelte';
const resolvers = [];
let f;
let count = $state(0);
let doubled = $derived(count * 2);
let delayed = $derived(await delay(count));
let nonnegative = $derived(delayed >= 0);
function delay(value) {
if (value === 0) return value;
return new Promise((resolve) => resolvers.push(() => resolve(value)));
}
function load(value, nonnegative) {
console.log([value, nonnegative]);
return value;
}
</script>
<button onclick={() => (f = fork(() => count++))}>fork</button>
<button onclick={() => resolvers.shift()?.()}>resolve</button>
<button onclick={() => f?.commit()}>commit</button>
<p>{await load(doubled, nonnegative)}</p>

@ -0,0 +1,50 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons =
'<button>fork</button><button>a = 2</button><button>shift</button><button>commit</button>';
// Like `async-fork-equal-result-dropped`, but a block depends on the async expression: the fork's
// re-run result equals the real value, but differs from the fork's outdated one, so the block has to
// update in the fork (its effects are considered up to date with the fork once it is committed)
export default test({
mode: ['client'],
async test({ assert, target }) {
const [fork, a2, shift, commit] = target.querySelectorAll('button');
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>other</p><span>0|0</span>`);
// the fork's result is 1
fork.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>other</p><span>0|0</span>`);
// the real world overtakes the fork's `a`, the real batch and the fork both run the expression with 2
a2.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>other</p><span>0|0</span>`);
// the real batch's result
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>other</p><span>2|0</span>`);
// the fork's result, which equals the real value
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>other</p><span>2|0</span>`);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>other</p><span>2|1</span>`);
// nothing left over
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>other</p><span>2|1</span>`);
}
});

@ -0,0 +1,28 @@
<script>
import { fork } from 'svelte';
let a = $state(0);
let b = $state(0);
/** @type {ReturnType<typeof fork> | null} */
let f = null;
/** @type {Array<() => void>} */
const deferred = [];
/** @param {number} value */
function delay(value) {
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<button onclick={() => (f = fork(() => { a = 1; b = 1; }))}>fork</button>
<button onclick={() => (a = 2)}>a = 2</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => f?.commit()}>commit</button>
<svelte:boundary>
{#if (await delay(a)) === 1}<p>one</p>{:else}<p>other</p>{/if}
<span>{a}|{b}</span>
{#snippet pending()}{/snippet}
</svelte:boundary>

@ -0,0 +1,8 @@
<script>
let { total, navigating } = $props();
const pages = $derived(Math.ceil(total / 28));
const pending = $derived(navigating && pages > 1);
</script>
{#if pending}pending{/if}
<p>{pages}</p>

@ -0,0 +1,50 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>preload</button>
<button>navigate</button>
<button>commit</button>
<button>discard</button>
`;
export default test({
async test({ assert, target, logs }) {
const [preload, navigate, commit, discard] = target.querySelectorAll('button');
preload.click();
// Let the async child resolve, without committing its fork.
await new Promise((resolve) => setTimeout(resolve, 0));
assert.htmlEqual(target.innerHTML, buttons);
assert.deepEqual(logs, []);
// A real-world update must not evaluate the speculative child in the real world.
navigate.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
assert.deepEqual(logs, []);
discard.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
assert.deepEqual(logs, []);
navigate.click();
await tick();
preload.click();
await new Promise((resolve) => setTimeout(resolve, 0));
navigate.click();
await tick();
assert.htmlEqual(target.innerHTML, buttons);
assert.deepEqual(logs, []);
commit.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}pending <p>2</p>`);
assert.deepEqual(logs, []);
navigate.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons}<p>2</p>`);
assert.deepEqual(logs, []);
}
});

@ -0,0 +1,20 @@
<script>
import { fork } from 'svelte';
import Child from './Child.svelte';
let show = $state(false);
let navigating = $state(false);
let f;
</script>
<button onclick={() => (f = fork(() => (show = true)))}>preload</button>
<button onclick={() => (navigating = !navigating)}>navigate</button>
<button onclick={() => f.commit()}>commit</button>
<button onclick={() => f.discard()}>discard</button>
{#if show}
<svelte:boundary onerror={(error) => console.log(error.message)}>
{#snippet pending()}loading{/snippet}
<Child total={await Promise.resolve(42)} {navigating} />
</svelte:boundary>
{/if}

@ -0,0 +1,14 @@
<script>
let { x, s, slow, log } = $props();
// async result of x; `d` only flips when it crosses 0, so incrementing x doesn't change it
let sx = $derived(await slow(x));
let d = $derived(sx > 0);
$effect(() => {
log(`${s}/${d}`);
});
</script>
<p>{sx}</p>
<p>{await slow(s)}</p>

@ -0,0 +1,33 @@
import { tick } from 'svelte';
import { test } from '../../test';
// When a later batch's write is hidden from an earlier batch, its write version must be
// hidden as well — otherwise reactions of that source look dirty to the earlier batch
// and re-run even though they see the old (unchanged) value
export default test({
async test({ assert, target, logs }) {
const [x, s, resolve] = target.querySelectorAll('button');
resolve.click();
await tick();
resolve.click();
await tick();
assert.deepEqual(logs, ['0/true']);
x.click(); // B1: slow(x = 2) pending; `d` stays true
await tick();
s.click(); // B2: slow(s = 1) pending; the $effect is dirtied by `s` and deferred in B2
await tick();
assert.deepEqual(logs, ['0/true']);
// B1 commits while B2 is pending. In B1's world nothing the effect depends on changed
// (`s` is hidden and still 0, `d` is still true), so it must not run
resolve.click();
await tick();
assert.deepEqual(logs, ['0/true']);
resolve.click();
await tick();
assert.deepEqual(logs, ['0/true', '1/true']);
}
});

@ -0,0 +1,23 @@
<script>
import Child from './Child.svelte';
let x = $state(1);
let s = $state(0);
const resolvers = [];
function slow(v) {
return new Promise((r) => resolvers.push(() => r(v)));
}
function log(v) {
console.log(v);
}
</script>
<button onclick={() => x++}>x</button>
<button onclick={() => s++}>s</button>
<button onclick={() => resolvers.shift()?.()}>resolve</button>
<button onclick={() => resolvers.pop()?.()}>resolve last</button>
<svelte:boundary>
{#snippet pending()}loading{/snippet}
<Child {x} {s} {slow} {log} />
</svelte:boundary>

@ -0,0 +1,31 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>start A: x = 1</button>
<button>start B: show branch</button>
<button>resolve A</button>
`;
export default test({
mode: ['client'],
async test({ assert, target }) {
await tick();
const [start_a, start_b, resolve] = target.querySelectorAll('button');
start_a.click();
await tick();
start_b.click();
await tick();
// B selected the empty branch before merging into A. A's async result
// now selects the content, which B's old commit callback must not remove.
// The two blocks register A's and B's callbacks in opposite orders.
resolve.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`${buttons}<p>1/1</p><strong>ready</strong><em>also ready</em>`
);
}
});

@ -0,0 +1,24 @@
<script>
let x = $state(0);
let show = $state(false);
let done;
function delay(value) {
if (value === 0) return 0;
return new Promise(resolve => { done = () => resolve(value); });
}
let result = $derived(await delay(x));
</script>
<button onclick={() => x = 1}>start A: x = 1</button>
<button onclick={() => show = true}>start B: show branch</button>
<button onclick={() => done()}>resolve A</button>
<p>{x}/{result}</p>
{#if show && x > 0 && result > 0}
<strong>ready</strong>
{/if}
{#if x > 0 && show && result > 0}
<em>also ready</em>
{/if}

@ -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>

@ -2,8 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO this one is tricky
async test({ assert, target }) {
const [increment, a, b] = target.querySelectorAll('button');
@ -31,27 +29,27 @@ export default test({
a.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift a</button>
<button>shift b</button>
<p>a: 0</p>
<p>a: 2</p>
`
);
b.click();
await tick();
b.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift a</button>
<button>shift b</button>
<p>b: 0</p
<p>b: 3</p
`
);
}

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO more combinations pass on https://github.com/sveltejs/svelte/pull/17971
timeout: 20_000,
async test({ assert, target }) {
const [x, fork_x, y, fork_y, shift, pop, commit_x, commit_y, reset] =

@ -2,7 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971
async test({ assert, target }) {
const [x, y, shift, pop, commit] = target.querySelectorAll('button');

@ -0,0 +1,32 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = `
<button>show</button>
<button>gate</button>
<button>resolve</button>
`;
export default test({
async test({ assert, target, logs }) {
const [show, gate, resolve] = target.querySelectorAll('button');
show.click();
await tick();
assert.deepEqual(logs, ['load']);
// An unrelated update re-runs the block while the first batch is still pending.
// It sees `show = false` (the pending batch's write is held back) and commits
// without the branch — but the pending batch still wants it, so the branch
// (and its in-flight request) must survive rather than be recreated
gate.click();
await tick();
assert.deepEqual(logs, ['load']);
assert.htmlEqual(target.innerHTML, buttons);
resolve.click();
await tick();
assert.deepEqual(logs, ['load']);
assert.htmlEqual(target.innerHTML, `${buttons}<p>loaded</p>`);
}
});

@ -0,0 +1,19 @@
<script>
let show = $state(false);
let gate = $state(1);
const deferred = [];
function load() {
console.log('load');
return new Promise((resolve) => deferred.push(() => resolve('loaded')));
}
</script>
<button onclick={() => (show = true)}>show</button>
<button onclick={() => gate++}>gate</button>
<button onclick={() => deferred.shift()?.()}>resolve</button>
{#if show && gate > 0}
<p>{await load()}</p>
{/if}
Loading…
Cancel
Save