From d28ea11509137375ae8632180b611fc446ce262b Mon Sep 17 00:00:00 2001
From: Simon Holthausen
Date: Thu, 15 Oct 2026 23:32:33 +0200
Subject: [PATCH] wip
---
.changeset/exact-batch-versions.md | 5 +
.../internal/client/dom/blocks/branches.js | 15 +-
.../src/internal/client/dom/blocks/each.js | 11 +
.../src/internal/client/reactivity/batch.js | 651 +++++++++++-------
.../internal/client/reactivity/deriveds.js | 171 +++--
.../src/internal/client/reactivity/effects.js | 10 +-
.../src/internal/client/reactivity/sources.js | 84 ++-
.../src/internal/client/reactivity/types.d.ts | 24 +-
.../svelte/src/internal/client/runtime.js | 455 ++++++++++--
.../Sync.svelte | 5 +
.../_config.js | 28 +
.../main.svelte | 33 +
.../Async.svelte | 6 +
.../Sync.svelte | 5 +
.../_config.js | 16 +
.../main.svelte | 18 +
.../async-branch-commit-order/_config.js | 27 +
.../async-branch-commit-order/main.svelte | 24 +
.../_config.js | 35 +
.../main.svelte | 23 +
.../_config.js | 31 +
.../main.svelte | 24 +
.../async-derived-batch-cache/Child.svelte | 20 +
.../async-derived-batch-cache/_config.js | 76 ++
.../async-derived-batch-cache/main.svelte | 25 +
.../_config.js | 44 ++
.../main.svelte | 25 +
.../_config.js | 37 +
.../main.svelte | 21 +
.../_config.js | 1 -
.../async-derived-not-overfiring/_config.js | 1 -
.../async-derived-not-underfiring/_config.js | 1 -
.../_config.js | 33 +
.../main.svelte | 38 +
.../async-effect-resume-no-rerun/Child.svelte | 9 +
.../async-effect-resume-no-rerun/_config.js | 37 +
.../async-effect-resume-no-rerun/main.svelte | 29 +
.../_config.js | 39 ++
.../main.svelte | 34 +
.../async-equal-result-dropped/_config.js | 47 ++
.../async-equal-result-dropped/main.svelte | 36 +
.../_config.js | 40 ++
.../main.svelte | 39 ++
.../async-fork-async-effect-replay/_config.js | 41 ++
.../main.svelte | 38 +
.../async-fork-branch-merge/_config.js | 35 +
.../async-fork-branch-merge/main.svelte | 25 +
.../async-fork-branch-reveal/_config.js | 54 ++
.../async-fork-branch-reveal/main.svelte | 22 +
.../_config.js | 53 ++
.../main.svelte | 25 +
.../_config.js | 41 ++
.../main.svelte | 27 +
.../_config.js | 50 ++
.../main.svelte | 28 +
.../Child.svelte | 8 +
.../async-fork-uninitialized-debug/_config.js | 50 ++
.../main.svelte | 20 +
.../Child.svelte | 14 +
.../_config.js | 33 +
.../main.svelte | 23 +
.../async-merge-branch-reselection/_config.js | 31 +
.../main.svelte | 24 +
.../_config.js | 49 ++
.../main.svelte | 27 +
.../samples/async-stale-derived-2/_config.js | 10 +-
.../_config.js | 1 -
.../async-state-new-branch-fork-1/_config.js | 1 -
.../_config.js | 32 +
.../main.svelte | 19 +
70 files changed, 2704 insertions(+), 410 deletions(-)
create mode 100644 .changeset/exact-batch-versions.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Async.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/Child.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-new-branch-unrelated-update/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-new-branch-unrelated-update/main.svelte
diff --git a/.changeset/exact-batch-versions.md b/.changeset/exact-batch-versions.md
new file mode 100644
index 0000000000..6907eebcbf
--- /dev/null
+++ b/.changeset/exact-batch-versions.md
@@ -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
diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js
index 141b914537..08efb3e202 100644
--- a/packages/svelte/src/internal/client/dom/blocks/branches.js
+++ b/packages/svelte/src/internal/client/dom/blocks/branches.js
@@ -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,
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index 58d59a6825..b87744c70f 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -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);
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 655dfc6598..83ab2d9f3a 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -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 | 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 | 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}
+ */
+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}
+ * @type {Map}
*/
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}
+ * @type {Map}
*/
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}
+ */
+ 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}
+ */
+ #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}
@@ -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} sources
+ */
+ #revalidate(sources) {
+ /** @type {Set} */
+ var seen = new Set();
+
+ for (const source of sources) {
+ this.#mark(source, seen);
+ }
+ }
+
+ /**
+ * @param {Value} value
+ * @param {Set} 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} */
- var marked = new Set();
-
- /** @type {Map} */
- 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} marked
- * @param {Map} 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} 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[]} */
@@ -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();
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 728ef1d214..a50a3066f6 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -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} */ (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} */ (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;
}
/**
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index e69a639a94..134429a26c 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -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;
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 14d7b9a2e5..9c60dfbadf 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -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} 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} */ (current), value)) {
+ Batch.ensure().keep(source);
+ } else if (!source.equals.call(/** @type {Value} */ (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);
}
}
}
diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts
index 0ee8570c3d..c038af262a 100644
--- a/packages/svelte/src/internal/client/reactivity/types.d.ts
+++ b/packages/svelte/src/internal/client/reactivity/types.d.ts
@@ -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 extends Value, Reaction {
/** The derived function */
fn: () => V;
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index f09aaf34ee..fae6a0b9ee 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -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 | 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`
diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte
new file mode 100644
index 0000000000..eee3e94e31
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte
@@ -0,0 +1,5 @@
+
+
+{c}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js
new file mode 100644
index 0000000000..6c6f8100e5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js
@@ -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 =
+ '';
+ assert.htmlEqual(target.innerHTML, `${buttons}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte
new file mode 100644
index 0000000000..057e1db394
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte
@@ -0,0 +1,5 @@
+
+
+{y}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js
new file mode 100644
index 0000000000..5f860cf2e6
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js
@@ -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, '
'
+ );
+ 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,
+ '
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/_config.js
new file mode 100644
index 0000000000..a5fd3ad7c5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/_config.js
@@ -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,
+ '
`
+ );
+ 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}
2/0
matched
`
+ );
+ 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}
2/0
matched
`
+ );
+
+ // 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]]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte
new file mode 100644
index 0000000000..1ef51268f2
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+ {#snippet pending()}loading{/snippet}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js
new file mode 100644
index 0000000000..a971621615
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js
@@ -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']);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte
new file mode 100644
index 0000000000..f1fe57e638
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
`);
+
+ // 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}
0:0
0
`);
+
+ // 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}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js
new file mode 100644
index 0000000000..4fd60a98ce
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js
@@ -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,
+ '
1
child
'
+ );
+
+ // 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']);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte
new file mode 100644
index 0000000000..30d68f614b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
{await push(x)}
+
+{#if show}
+
+
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js
new file mode 100644
index 0000000000..e487dea160
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js
@@ -0,0 +1,39 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+const buttons =
+ '';
+
+// 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}
0c0`);
+
+ // 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}
zero
0c0`);
+
+ // the earlier batch's equal result completes it
+ resolve_oldest.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
+ {b}
+ {await f('c' + c)}
+ {#snippet pending()}{/snippet}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js
new file mode 100644
index 0000000000..44218567ae
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js
@@ -0,0 +1,47 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+const buttons =
+ '';
+
+// 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}k0k1`);
+
+ // batch 1 requests k2/k3
+ b2.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}k0k1`);
+
+ // batch 2 requests k0/k1, which equal the values currently shown
+ b0.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}k0k1`);
+
+ resolve_k3.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}k0k1`);
+
+ // batch 2's first result equals the real value
+ resolve_k0.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}k0k1`);
+
+ resolve_all.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}k0k1`);
+
+ // nothing left over
+ resolve_all.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}k0k1`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte
new file mode 100644
index 0000000000..d3cfaadd9d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+ {await delay('k' + (0 + b))}{await delay('k' + (1 + b))}
+ {#snippet pending()}{/snippet}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js b/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js
new file mode 100644
index 0000000000..3cc13951df
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js
@@ -0,0 +1,40 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+const buttons =
+ '';
+
+// 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}
0c0`);
+
+ // 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}
zero
0c0`);
+
+ // the earlier batch's equal result completes it
+ resolve_oldest.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/_config.js
new file mode 100644
index 0000000000..0b62c027f5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/_config.js
@@ -0,0 +1,54 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+const buttons = `
+
+
+
+
+
+
+`;
+
+// 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}static
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/_config.js
new file mode 100644
index 0000000000..7d9a66b8a3
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/_config.js
@@ -0,0 +1,50 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+const buttons =
+ '';
+
+// 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}
other
0|0`);
+
+ // the fork's result is 1
+ fork.click();
+ await tick();
+ shift.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
other
0|0`);
+
+ // 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}
other
0|0`);
+
+ // the real batch's result
+ shift.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
other
2|0`);
+
+ // the fork's result, which equals the real value
+ shift.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js
new file mode 100644
index 0000000000..0bedc308a3
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js
@@ -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']);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte
new file mode 100644
index 0000000000..32e1cfb6b3
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+ {#snippet pending()}loading{/snippet}
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js
new file mode 100644
index 0000000000..c21996a615
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js
@@ -0,0 +1,31 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+const buttons = `
+
+
+
+`;
+
+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}
`);
+
+ // 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}
q0 r0 p0 t0 s0
`);
+
+ // B's run resolves -> B must wait for A0
+ resolve_t.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
q0 r0 p0 t0 s0
`);
+
+ // A0's run resolves -> everything commits at once
+ resolve_r.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `${buttons}
show
+
+
+`;
+
+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}