diff --git a/.changeset/entangled-batches.md b/.changeset/entangled-batches.md new file mode 100644 index 0000000000..c5bba0956e --- /dev/null +++ b/.changeset/entangled-batches.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +refactor: entangle overlapping async batches instead of time-travelling their shared reactivity graph diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 043b50b4b2..57726caf94 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -52,6 +52,13 @@ export const EFFECT_OFFSCREEN = 1 << 25; * should not be used for any other purpose! */ export const WAS_MARKED = 1 << 16; +/** + * A derived created for a template expression. These are leaves of the reactivity + * graph (there could theoretically be one per read value), so they don't entangle + * batches — instead, while multiple batches exist, they are evaluated per-world + * without caching + */ +export const TEMPLATE_EXPRESSION = 1 << 26; // Flags used for async export const REACTION_IS_UPDATING = 1 << 21; diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 2e7df92dc0..348ae4d856 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -1,5 +1,5 @@ /** @import { Blocker, Effect, Source, Value } from '#client' */ -import { DESTROYED, STALE_REACTION } from '#client/constants'; +import { DESTROYED, STALE_REACTION, TEMPLATE_EXPRESSION } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, @@ -39,7 +39,13 @@ export function flatten(blockers, sync, async, fn) { // Filter out already-settled blockers - no need to wait for them var pending = blockers.filter((b) => !b.settled); - var deriveds = sync.map(d); + var deriveds = sync.map((expression) => { + var signal = d(expression); + // template expressions are leaves of the reactivity graph — they don't + // entangle batches, and are evaluated per-world while batches overlap + signal.f |= TEMPLATE_EXPRESSION; + return signal; + }); if (DEV) { deriveds.forEach((d, i) => { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 31ab17eaea..4d061559be 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -17,10 +17,12 @@ import { ERROR_VALUE, MANAGED_EFFECT, REACTION_RAN, - DESTROYING + DESTROYING, + USER_EFFECT, + TEMPLATE_EXPRESSION } 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, @@ -32,13 +34,18 @@ import * as e from '../errors.js'; import { flush_tasks, queue_micro_task } from '../dom/task.js'; import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; -import { flush_eager_effects, old_values, set_eager_effects, source, update } from './sources.js'; +import { + eager_effects, + flush_eager_effects, + mark_reactions, + old_values, + source, + update +} from './sources.js'; 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 { invariant } from '../../shared/dev.js'; -import { log_effect_tree } from '../dev/debug.js'; import { OBSOLETE } from './deriveds.js'; /** @type {Batch | null} */ @@ -59,11 +66,22 @@ 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. + * Entries are `[value, owner]` tuples — `owner` is the live batch whose + * pre-write value we are seeing (so that the reader can be re-run when + * that batch commits), or `null` if the value belongs to the current batch + * @type {Map | null} */ export let batch_values = null; +/** + * The batch whose world `batch_values` currently reflects. This can differ from + * `current_batch`, e.g. while a batch's effects are being flushed (during which + * `current_batch` is `null`, or a new batch created by writes inside effects) + * @type {Batch | null} + */ +let batch_values_owner = null; + /** @type {Effect | null} */ let last_scheduled_effect = null; @@ -112,16 +130,16 @@ 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) - * They keys of this map are identical to `this.#previous` - * @type {Map} + * The current values of any sources that are updated in this batch + * (including deriveds that were overridden via assignment) + * @type {Map} */ current = new Map(); /** - * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place. - * They keys of this map are identical to `this.#current` + * The values of any signals (sources and deriveds) that are updated in this + * batch _before_ those updates took place. Other, non-overlapping batches + * use these to keep operating against the pre-write world until this batch commits * @type {Map} */ previous = new Map(); @@ -170,12 +188,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) that are DIRTY * @type {Set} @@ -197,13 +209,41 @@ export class Batch { */ #skipped_branches = new Map(); + is_fork = false; + /** - * Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing - * @type {Set} + * `true` for batches created by `$state.eager` version bumps. Like template + * expressions, these are 'leaves' — they re-run affected template/block + * effects immediately in their own world, without entangling other batches */ - #unskipped_branches = new Set(); + is_eager = false; - is_fork = false; + /** + * If this batch was merged into another one (because their reactivity graphs + * turned out to overlap), this points to the batch it was merged into. Stale + * references to this batch (claims on reactions, batches captured in async + * closures, etc) are forwarded to the survivor + * @type {Batch | null} + */ + merged_into = null; + + /** + * Deriveds evaluated inside this fork. Forks don't write values through — + * this map is the fork's own view of the affected part of the graph, and is + * discarded along with the fork. Committing writes the fork's sources + * through, after which deriveds recompute in the real world. + * `null` unless this batch is (or was) a fork + * @type {Map | null} + */ + fork_values = null; + + /** + * Reactions that observed the pre-write world of this batch (via + * `batch_values`) while it was pending. When this batch commits, they + * re-run with the real values + * @type {Set} + */ + stale_readers = new Set(); #decrement_queued = false; @@ -251,31 +291,182 @@ export class Batch { if (!this.#skipped_branches.has(effect)) { this.#skipped_branches.set(effect, { d: [], m: [] }); } - this.#unskipped_branches.delete(effect); } /** * 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 */ - unskip_effect(effect, callback = (e) => this.schedule(e)) { + unskip_effect(effect) { 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); + this.schedule(e); } for (e of tracked.m) { set_signal_status(e, MAYBE_DIRTY); - callback(e); + this.schedule(e); + } + } + } + + /** + * If this batch was merged into another one, get the surviving batch + * @returns {Batch} + */ + #resolved() { + /** @type {Batch} */ + var batch = this; + while (batch.merged_into !== null) batch = batch.merged_into; + return batch; + } + + /** + * Take ownership of a reaction. Deriveds and user/block/async effects can + * only ever belong to one live batch — if the reaction is already claimed + * by another live batch, the two reactivity graphs overlap, which means the + * batches describe the same 'world', and they are merged into one. + * Template-level (render/managed) effects are exempt: they are leaves, so + * independent batches can share them without their graphs being entangled. + * Forks are also exempt — they are speculative and live in their own world + * @param {Reaction} reaction + */ + claim(reaction) { + if (!async_mode_flag || this.is_fork) return; + + if ((reaction.f & (DERIVED | ASYNC | BLOCK_EFFECT | USER_EFFECT)) === 0) return; + + // template expression deriveds are leaves — they don't entangle + if ((reaction.f & TEMPLATE_EXPRESSION) !== 0) return; + + var owner = reaction.batch; + + if (owner !== null) { + while (owner.merged_into !== null) owner = owner.merged_into; + } + + if (this.is_eager) { + // eager version bumps don't entangle — but an effect that belongs to + // another live batch's world, and which we are about to run in ours, + // must afterwards be re-established in the owner's world + if ( + (reaction.f & DERIVED) === 0 && + owner !== null && + owner !== this && + owner.linked && + !owner.is_fork + ) { + owner.#dirty_effects.add(/** @type {Effect} */ (reaction)); } + + return; + } + + if (owner !== null && owner !== this && owner.linked && !owner.is_fork) { + this.#merge(owner); + } + + reaction.batch = this; + } + + /** + * Absorb another batch into this one — their reactivity graphs overlap, + * so they describe a single 'world'. All state is transferred, and stale + * references to `other` are forwarded to `this` via `merged_into` + * @param {Batch} other + */ + #merge(other) { + var other_is_older = other.id < this.id; + + // for signals touched by both batches, keep the oldest `previous` + // (the value from before either batch touched it)... + for (const [source, previous] of other.previous) { + if (other_is_older || !this.previous.has(source)) { + this.previous.set(source, previous); + } + } + + // ...and the newest current value + for (const [source, value] of other.current) { + if (!other_is_older || !this.current.has(source)) { + this.current.set(source, value); + } + } + + for (const [effect, d] of other.async_deriveds) { + if (this.async_deriveds.has(effect)) { + // both batches have an in-flight run of the same async effect + // (this can happen via forks, which don't entangle) — `other`'s + // run belongs to a world that no longer exists independently + d.reject(OBSOLETE); + } else { + this.async_deriveds.set(effect, d); + } + } + other.async_deriveds.clear(); + + this.#pending += other.#pending; + other.#pending = 0; + + for (const [effect, count] of other.#blocking_pending) { + this.#blocking_pending.set(effect, (this.#blocking_pending.get(effect) ?? 0) + count); + } + other.#blocking_pending.clear(); + + this.transfer_effects(other.#dirty_effects, other.#maybe_dirty_effects); + + for (const [effect, tracked] of other.#skipped_branches) { + var existing = this.#skipped_branches.get(effect); + if (existing) { + existing.d.push(...tracked.d); + existing.m.push(...tracked.m); + } else { + this.#skipped_branches.set(effect, tracked); + } + } + other.#skipped_branches.clear(); + + // commit/discard callbacks stay bound to the batch they were registered + // under — consumers (branch managers etc) key their state by batch + for (const fn of other.#commit_callbacks) { + this.#commit_callbacks.add(() => fn(other)); + } + other.#commit_callbacks.clear(); + + for (const fn of other.#discard_callbacks) { + this.#discard_callbacks.add(() => fn(other)); + } + other.#discard_callbacks.clear(); + + for (const effect of other.#scheduled) { + this.#scheduled.push(effect); + } + other.#scheduled = []; + + for (const reader of other.stale_readers) { + this.stale_readers.add(reader); + } + other.stale_readers.clear(); + + // `other`'s settled() promise resolves when this batch settles + if (other.#deferred !== null) { + const d = other.#deferred; + this.settled().then(d.resolve, d.reject); + } + + other.merged_into = this; + other.#unlink(); + + // if we're mid-flush, `batch_values` was holding back `other`'s values — + // they are part of this batch's world now, so recompute the overrides + if (batch_values !== null && current_batch === this) { + this.apply(); } - this.#unskipped_branches.add(effect); } /** @@ -420,17 +611,6 @@ export class Batch { return; } - const earlier_batch = this.#find_earlier_batch(); - - if (earlier_batch) { - // If this batch collected deferred effects during traversal, they still need - // to run after being merged into the earlier batch. - this.#defer_effects(render_effects); - this.#defer_effects(effects); - earlier_batch.#merge(this); - return; - } - // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. this.#dirty_effects.clear(); this.#maybe_dirty_effects.clear(); @@ -451,14 +631,17 @@ export class Batch { if (this.#pending === 0 && (this.#scheduled.length === 0 || next_batch !== null)) { this.#unlink(); - // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, - // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. - // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode - // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed if (async_mode_flag) { + // now that this batch is committed, reactions that observed its + // pre-write values (via `batch_values`) re-run with the real ones this.#commit(); - // Rebases can activate other batches or null it out, therefore restore the new one here - current_batch = next_batch; + + // #commit may have scheduled stale readers into a new batch + if (next_batch === null) { + next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); + } else { + current_batch = next_batch; + } } } @@ -534,95 +717,23 @@ export class Batch { } } - #find_earlier_batch() { - var batch = this.#prev; - - 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) { - return batch; - } - } - } - - batch = batch.#prev; - } - - return null; - } - /** - * @param {Batch} batch + * @param {Effect[]} effects */ - #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.current.set(source, value); - } - - for (const [effect, deferred] of batch.async_deriveds) { - const d = this.async_deriveds.get(effect); - if (d) deferred.promise.then(d.resolve).catch(d.reject); - } - - // 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_effects(batch.#dirty_effects, batch.#maybe_dirty_effects); - - /** - * 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; - - 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.#maybe_dirty_effects.delete(effect); - set_signal_status(effect, DIRTY); - this.schedule(effect); - } - } - } - }; - - for (const source of this.current.keys()) { - mark(source); + #defer_effects(effects) { + for (var i = 0; i < effects.length; i += 1) { + defer_effect(effects[i], this.#dirty_effects, this.#maybe_dirty_effects); } - - this.oncommit(() => batch.discard()); - batch.#unlink(); - - current_batch = this; - this.#process(); } /** - * @param {Effect[]} effects + * Note a value's pre-write state, so that other (non-overlapping) batches + * can keep operating against the world as it was before this batch's writes + * @param {Value} source */ - #defer_effects(effects) { - for (var i = 0; i < effects.length; i += 1) { - defer_effect(effects[i], this.#dirty_effects, this.#maybe_dirty_effects); + record_previous(source) { + if (source.v !== UNINITIALIZED && !this.previous.has(source)) { + this.previous.set(source, source.v); } } @@ -631,17 +742,14 @@ export class Batch { * batch, noting its previous and current values * @param {Value} source * @param {any} value - * @param {boolean} [is_derived] */ - capture(source, value, is_derived = false) { - if (source.v !== UNINITIALIZED && !this.previous.has(source)) { - this.previous.set(source, source.v); - } + capture(source, value) { + this.record_previous(source); // 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); + this.current.set(source, value); + batch_values?.set(source, [value, null]); } if (!this.is_fork) { @@ -650,12 +758,13 @@ export class Batch { } activate() { - current_batch = this; + current_batch = this.#resolved(); } deactivate() { current_batch = null; batch_values = null; + batch_values_owner = null; } flush() { @@ -677,6 +786,7 @@ export class Batch { current_batch = null; batch_values = null; + batch_values_owner = null; old_values.clear(); @@ -700,134 +810,26 @@ 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 - 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 - - if (is_earlier && value !== batch_value) { - // bring the value up to date - batch.current.set(source, [value, is_derived]); - } else { - // same value or later batch has more recent value, - // no need to re-run these effects - continue; - } - } - - sources.push(source); - } + if (this.stale_readers.size === 0) return; - if (is_earlier) { - // TODO do we need to restart these in some cases, instead of - // immediately resolving them? Likely not because of how this.apply() works. - for (const [effect, deferred] of this.async_deriveds) { - const d = batch.async_deriveds.get(effect); - if (d) deferred.promise.then(d.resolve).catch(d.reject); - } - } - - 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; - - // 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)); - - if (others.length === 0) { - if (is_earlier) { - // this batch is now obsolete and can be discarded - batch.discard(); - } - } else if (sources.length > 0) { - // The microtask queue can contain the batch already scheduled to run right - // after this one is finished, so throwing the invariant would be wrong here. - if (DEV && !batch.#decrement_queued) { - invariant(batch.#scheduled.length === 0, 'Batch has scheduled effects'); - } + var readers = this.stale_readers; + this.stale_readers = new Set(); - // 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) => { - if ((e.f & (BLOCK_EFFECT | ASYNC)) !== 0) { - batch.schedule(e); - } else { - batch.#defer_effects([e]); - } - }); - } - } - - batch.activate(); - - /** @type {Set} */ - var marked = new Set(); + var batch = Batch.ensure(); - /** @type {Map} */ - var checked = new Map(); + for (const reader of readers) { + var flags = reader.f; - for (var source of sources) { - mark_effects(source, others, marked, checked); - } + if ((flags & (DESTROYED | INERT | DIRTY)) !== 0) continue; - 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.#dirty_effects.add(effect); - } - } - } - } + set_signal_status(reader, 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(); + if ((flags & DERIVED) !== 0) { + // invalidate anything that depends on the derived + mark_reactions(/** @type {Derived} */ (reader), MAYBE_DIRTY, null); + } else { + batch.schedule(/** @type {Effect} */ (reader)); } } } @@ -837,6 +839,11 @@ export class Batch { * @param {Effect} effect */ increment(blocking, effect) { + if (this.merged_into !== null) { + this.#resolved().increment(blocking, effect); + return; + } + this.#pending += 1; if (blocking) { @@ -850,6 +857,11 @@ export class Batch { * @param {Effect} effect */ decrement(blocking, effect) { + if (this.merged_into !== null) { + this.#resolved().decrement(blocking, effect); + return; + } + this.#pending -= 1; if (blocking) { @@ -879,12 +891,14 @@ export class Batch { * @param {Set} maybe_dirty_effects */ transfer_effects(dirty_effects, maybe_dirty_effects) { + var batch = this.#resolved(); + for (const e of dirty_effects) { - this.#dirty_effects.add(e); + batch.#dirty_effects.add(e); } for (const e of maybe_dirty_effects) { - this.#maybe_dirty_effects.add(e); + batch.#maybe_dirty_effects.add(e); } dirty_effects.clear(); @@ -902,7 +916,8 @@ export class Batch { } settled() { - return (this.#deferred ??= deferred()).promise; + var batch = this.#resolved(); + return (batch.#deferred ??= deferred()).promise; } static ensure() { @@ -922,50 +937,40 @@ export class Batch { } apply() { - if (!async_mode_flag || (!this.is_fork && this.#prev === null && this.#next === null)) { + var batch = this.#resolved(); + + if (!async_mode_flag || (!batch.is_fork && batch.#prev === null && batch.#next === null)) { batch_values = null; + batch_values_owner = 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); - } - - // ...and undo changes belonging to other batches unless they intersect - for (let batch = first_batch; batch !== null; batch = batch.#next) { - if (batch === this || batch.is_fork) continue; + /** @type {Map} */ + var values = (batch_values = new Map()); + batch_values_owner = batch; - // If two batches intersect, the latter batch will be merged into the earlier batch, - // and we should treat them as a single set of changes - var intersects = false; + // undo changes belonging to other live batches — aside from our own + // changes, we should only see values that have been committed. Batches + // whose graphs overlap were already merged, so there is no ambiguity + for (let other = first_batch; other !== null; other = other.#next) { + if (other === batch || other.is_fork) continue; - 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; - - if (this.current.has(source)) { - intersects = true; - break; - } + for (const [source, previous] of other.previous) { + if (!values.has(source)) { + values.set(source, [previous, other]); } } + } - // Since the latter batch merges into the earlier (if it resolves before the earlier one), - // we treat the earlier values as "already applied". This way we don't need to rerun async - // 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) { - for (const [source, previous] of batch.previous) { - if (!batch_values.has(source)) { - batch_values.set(source, previous); - } - } + // our own writes (and, in a fork, deriveds evaluated inside the fork) + // take precedence over everything else + for (const [source, value] of batch.current) { + values.set(source, [value, null]); + } + + if (batch.fork_values !== null) { + for (const [derived, value] of batch.fork_values) { + values.set(derived, [value, null]); } } } @@ -977,6 +982,8 @@ export class Batch { schedule(effect) { last_scheduled_effect = effect; + this.claim(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 ( @@ -1166,91 +1173,29 @@ function flush_queued_effects(effects) { } /** - * 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 - * function allows us to discover them - * @param {Value} value - * @param {Set} effects + * @param {Effect} effect + * @returns {void} */ -function mark_eager_effects(value, effects) { - if (value.reactions === null) return; - - for (const reaction of value.reactions) { - const flags = reaction.f; - - if ((flags & DERIVED) !== 0) { - mark_eager_effects(/** @type {Derived} */ (reaction), effects); - } else if ((flags & EAGER_EFFECT) !== 0) { - set_signal_status(reaction, DIRTY); - effects.add(/** @type {Effect} */ (reaction)); - } - } +export function schedule_effect(effect) { + /** @type {Batch} */ (current_batch).schedule(effect); } /** + * If the given reaction was claimed by (i.e. belongs to the world of) a live + * batch other than the current one, returns that batch. Such reactions are + * 'frozen' while operating in another batch's context — they must not be + * recomputed, nor have their status reset, as the owning batch relies on it * @param {Reaction} reaction - * @param {Source[]} sources - * @param {Map} checked + * @returns {Batch | null} */ -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; - } - } - } +export function claimed_by_other(reaction) { + var owner = reaction.batch; + if (owner === null) return null; - checked.set(reaction, false); + while (owner.merged_into !== null) owner = owner.merged_into; + reaction.batch = owner; - return false; -} - -/** - * @param {Effect} effect - * @returns {void} - */ -export function schedule_effect(effect) { - /** @type {Batch} */ (current_batch).schedule(effect); + return owner.linked && owner !== batch_values_owner ? owner : null; } /** @type {Source[]} */ @@ -1258,6 +1203,8 @@ let eager_versions = []; function eager_flush() { flushSync(() => { + Batch.ensure().is_eager = true; + const eager = eager_versions; eager_versions = []; for (const version of eager) { @@ -1325,6 +1272,60 @@ export function eager(fn) { return value; } +/** + * When a fork is committed, deriveds affected by its writes must recompute with + * the now-committed values, and template/user effects must re-run. Async and + * block effects are skipped — they already ran inside the fork with these + * values (their results were captured by the fork's batch), and eager effects + * are collected so that `$state.eager(...)` expressions update immediately + * @param {Value} value + * @param {Batch} batch + * @param {Set} marked + */ +function mark_committed_reactions(value, batch, marked) { + if (marked.has(value)) return; + marked.add(value); + + var reactions = value.reactions; + if (reactions === null) return; + + for (const reaction of reactions) { + var flags = reaction.f; + + if ((flags & EAGER_EFFECT) !== 0) { + set_signal_status(reaction, DIRTY); + eager_effects.add(/** @type {Effect} */ (reaction)); + } else if ((flags & DERIVED) !== 0) { + batch.claim(reaction); + + if ((flags & DIRTY) === 0) { + set_signal_status(reaction, MAYBE_DIRTY); + } + + mark_committed_reactions(/** @type {Derived} */ (reaction), batch, marked); + } else if ((flags & (ASYNC | BLOCK_EFFECT)) !== 0) { + // async and block effects that the fork itself ran are left alone — + // they already ran with these exact values. But if such an effect + // belongs to another live batch's world, it must re-run with the + // committed values (which also entangles us with that batch, as + // with any other write) + var owner = /** @type {Effect} */ (reaction).batch; + while (owner !== null && owner.merged_into !== null) owner = owner.merged_into; + + if (owner !== null && owner !== batch && owner.linked && !owner.is_fork) { + if ((reaction.f & DIRTY) === 0) { + set_signal_status(reaction, DIRTY); + } + + batch.schedule(/** @type {Effect} */ (reaction)); + } + } else if ((flags & DIRTY) === 0) { + set_signal_status(reaction, DIRTY); + batch.schedule(/** @type {Effect} */ (reaction)); + } + } +} + /** * Mark all the effects inside a skipped branch CLEAN, so that * they can be correctly rescheduled later. Tracks dirty and maybe_dirty @@ -1397,7 +1398,8 @@ export function fork(fn) { var batch = Batch.ensure(); batch.is_fork = true; - batch_values = new Map(); + batch.fork_values = new Map(); + batch.apply(); var committed = false; var settled = batch.settled(); @@ -1418,29 +1420,30 @@ export function fork(fn) { committed = true; batch.is_fork = false; + batch.fork_values = null; + + // Write the fork's changes through and invalidate affected deriveds + // and template/user effects, so they recompute with the committed + // values. Async and block effects already ran inside the fork with + // these exact values, so they are left alone. Claiming the deriveds + // entangles us with any overlapping pending batches + batch.activate(); + + /** @type {Set} */ + var marked = new Set(); - // apply changes and update write versions so deriveds see the change - for (var [source, [value]] of batch.current) { + for (var [source, value] of batch.current) { source.v = value; source.wv = increment_write_version(); + mark_committed_reactions(source, batch, marked); } - // trigger any `$state.eager(...)` expressions with the new state. - // eager effects don't get scheduled like other effects, so we - // can't just encounter them during traversal, we need to - // proactively flush them - // TODO maybe there's a better implementation? - flushSync(() => { - /** @type {Set} */ - var eager_effects = new Set(); - - for (var source of batch.current.keys()) { - mark_eager_effects(source, eager_effects); - } + batch.deactivate(); - set_eager_effects(eager_effects); - flush_eager_effects(); - }); + // trigger any `$state.eager(...)` expressions with the new state. + // eager effects don't get scheduled like other effects — marking + // collected them, but nothing flushes them during a fork commit + flush_eager_effects(); batch.flush(); await settled; diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index fd64f3b45d..94d1547872 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -31,13 +31,7 @@ import { import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; import * as w from '../warnings.js'; -import { - async_effect, - destroy_effect, - destroy_effect_children, - effect_tracking, - teardown -} from './effects.js'; +import { async_effect, destroy_effect, destroy_effect_children, teardown } from './effects.js'; import { eager_effects, internal_set, set_eager_effects, source } from './sources.js'; import { get_error } from '../../shared/dev.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; @@ -90,7 +84,8 @@ export function derived(fn) { v: /** @type {V} */ (UNINITIALIZED), wv: 0, parent: active_effect, - ac: null + ac: null, + batch: null }; if (DEV && tracing_mode_flag) { @@ -391,33 +386,46 @@ export function execute_derived(derived) { */ export function update_derived(derived) { var value = execute_derived(derived); + var batch = current_batch ?? previous_batch; + var fork_values = batch !== null && batch.is_fork ? batch.fork_values : null; + + if (fork_values !== null && derived.deps !== null) { + // Inside a fork, neither the underlying value nor the status are + // updated — the fork's view of the derived lives in the fork's own + // overlay, and is simply dropped if the fork is discarded, while the + // real status keeps describing the real (untouched) value. (Deriveds + // without dependencies never recompute, so they are treated like the + // real world below.) + var previous = fork_values.has(derived) ? fork_values.get(derived) : derived.v; + + if ( + previous === UNINITIALIZED || + !derived.equals.call(/** @type {any} */ ({ v: previous }), value) + ) { + derived.wv = increment_write_version(); + } + + fork_values.set(derived, value); + batch_values?.set(derived, [value, null]); + + return; + } if (!derived.equals(value)) { derived.wv = increment_write_version(); - // in a fork, we don't update the underlying value, just `batch_values`. - // the underlying value will be updated when the fork is committed. - // otherwise, the next time we get here after a 'real world' state - // change, `derived.equals` may incorrectly return `true` - if (!current_batch?.is_fork || derived.deps === null) { - if (current_batch !== null) { - // We also write to previous_batch because if it exists, it is a sign that we're - // currently in the process of flushing effects. These updates to deriveds may belong - // to the previous batch, not the new one (which can already exist if an earlier - // effect wrote to a source). This can cause bugs when running batch.#commit() later, - // but not adding it to current_batch can, too, so we add it to both. - // See https://github.com/sveltejs/svelte/pull/18117 for more details. - current_batch.capture(derived, value, true); - previous_batch?.capture(derived, value, true); - } else { - derived.v = value; - } + // note the previous value, so that other (non-overlapping) batches can + // keep operating against the pre-write world until this one commits + if (batch !== null && !batch.is_fork) { + batch.record_previous(derived); + } - // deriveds without dependencies should never be recomputed - if (derived.deps === null) { - set_signal_status(derived, CLEAN); - return; - } + derived.v = value; + + // deriveds without dependencies should never be recomputed + if (derived.deps === null) { + set_signal_status(derived, CLEAN); + return; } } @@ -427,17 +435,7 @@ export function update_derived(derived) { return; } - // During time traveling we don't want to reset the status so that - // traversal of the graph in the other batches still happens - if (batch_values !== null) { - // only cache the value if we're in a tracking context, otherwise we won't - // clear the cache in `mark_reactions` when dependencies are updated - if (effect_tracking() || current_batch?.is_fork) { - batch_values.set(derived, value); - } - } else { - update_derived_status(derived); - } + update_derived_status(derived); } /** diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index c5d195dfae..bdd81a792e 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -112,14 +112,17 @@ function create_effect(type, fn) { prev: null, teardown: null, wv: 0, - ac: null + ac: null, + batch: null }; if (DEV) { effect.component_function = dev_current_component_function; } - current_batch?.register_created_effect(effect); + // effects created while a batch is active belong to that batch's world + // (claiming is a no-op for template-level effects and inside forks) + current_batch?.claim(effect); /** @type {Effect | null} */ var e = effect; diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 218941ab67..227086062f 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -38,6 +38,7 @@ import { component_context, is_runes } from '../context.js'; import { Batch, batch_values, + current_batch, eager_block_effects, schedule_effect, legacy_updates @@ -223,9 +224,9 @@ export function internal_set(source, value, updated_during_traversal = null) { execute_derived(derived); } - // During time traveling we don't want to reset the status so that - // traversal of the graph in the other batches still happens - if (batch_values === null) { + // inside a fork the real value is untouched, so the status + // (which describes the real value) must be left alone too + if (!batch.is_fork) { update_derived_status(derived); } } @@ -334,7 +335,7 @@ export function increment(source) { * @param {Effect[] | null} updated_during_traversal * @returns {void} */ -function mark_reactions(signal, status, updated_during_traversal) { +export function mark_reactions(signal, status, updated_during_traversal) { var reactions = signal.reactions; if (reactions === null) return; @@ -363,7 +364,15 @@ function mark_reactions(signal, status, updated_during_traversal) { } else if ((flags & DERIVED) !== 0) { var derived = /** @type {Derived} */ (reaction); - batch_values?.delete(derived); + // claiming the derived for the current batch merges any other live + // batch whose reactivity graph overlaps with ours into it + current_batch?.claim(derived); + + // invalidate any world-local memoized values + if (batch_values !== null) { + batch_values.delete(derived); + current_batch?.fork_values?.delete(derived); + } if ((flags & WAS_MARKED) === 0) { // Only connected deriveds being executed outside the update cycle can be reliably unmarked right away diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 0ee8570c3d..d54779e177 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -7,6 +7,7 @@ import type { TransitionManager } from '#client'; import type { Boundary } from '../dom/blocks/boundary'; +import type { Batch } from './batch.js'; export interface Signal { /** Flags bitmask */ @@ -50,6 +51,13 @@ export interface Reaction extends Signal { deps: null | Value[]; /** An AbortController that aborts when the signal is destroyed */ ac: null | AbortController; + /** + * The batch that most recently claimed this reaction by marking it dirty. + * Only set for deriveds and user/block/async effects — if two batches claim + * the same reaction, their reactivity graphs overlap and they are merged. + * A claim expires when its batch is committed or discarded (`!batch.linked`) + */ + batch: null | Batch; } export interface Derived extends Value, Reaction { diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 227203523e..0470a02407 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -23,7 +23,8 @@ import { ERROR_VALUE, WAS_MARKED, MANAGED_EFFECT, - REACTION_RAN + REACTION_RAN, + TEMPLATE_EXPRESSION } from './constants.js'; import { old_values } from './reactivity/sources.js'; import { @@ -49,6 +50,7 @@ import { import { Batch, batch_values, + claimed_by_other, current_batch, flushSync, previous_batch, @@ -168,6 +170,27 @@ export function is_dirty(reaction) { for (var i = 0; i < length; i++) { var dependency = dependencies[i]; + if (batch_values !== null && (dependency.f & DERIVED) !== 0) { + var is_template = (dependency.f & TEMPLATE_EXPRESSION) !== 0; + + if (is_template || claimed_by_other(/** @type {Derived} */ (dependency)) !== null) { + // deriveds that are evaluated per-world (in `get`) while multiple + // batches exist: if dirty, treat them as changed — the dirtiness + // may originate from a write in our own world, and re-running the + // reaction is harmless otherwise + if ((dependency.f & (DIRTY | MAYBE_DIRTY)) !== 0) { + return true; + } + + if (!is_template) { + // a clean derived belonging to another live batch's world is + // unchanged in ours — the owner's recompute (which may have + // bumped its write version) doesn't affect our world + continue; + } + } + } + if (is_dirty(/** @type {Derived} */ (dependency))) { update_derived(/** @type {Derived} */ (dependency)); } @@ -177,12 +200,7 @@ export function is_dirty(reaction) { } } - if ( - (flags & CONNECTED) !== 0 && - // During time traveling we don't want to reset the status so that - // traversal of the graph in the other batches still happens - batch_values === null - ) { + if ((flags & CONNECTED) !== 0) { set_signal_status(reaction, CLEAN); } } @@ -659,34 +677,71 @@ export function get(signal) { return value; } - // connect disconnected deriveds if we are reading them inside an effect, - // or inside another derived that is already connected - var should_connect = - (derived.f & CONNECTED) === 0 && - !untracking && - active_reaction !== null && - (is_updating_effect || (active_reaction.f & CONNECTED) !== 0); - - var is_new = (derived.f & REACTION_RAN) === 0; + // While multiple batches exist, some deriveds must be evaluated in the + // context of the current world, without touching their cached state: + // - deriveds belonging to another live batch's world must not be + // recomputed or have their status reset (the owning batch relies on + // both) — their value in this world follows from `batch_values` + // - dirty template expression deriveds are leaves that can be shared + // between non-overlapping batches, so each world evaluates its own value + /** @type {Batch | null} */ + var owner = null; - if (is_dirty(derived)) { - if (should_connect) { - // set the flag before `update_derived`, so that the derived - // is added as a reaction to its dependencies - derived.f |= CONNECTED; + if ( + batch_values !== null && + ((derived.f & TEMPLATE_EXPRESSION) !== 0 + ? (derived.f & (DIRTY | MAYBE_DIRTY)) !== 0 + : (owner = claimed_by_other(derived)) !== null) + ) { + // the world-local value is memoized in `batch_values` (and invalidated + // there when dependencies change). Reads are registered with the owner + // batch — when it commits, the reader re-runs with the real values + if (!batch_values.has(derived)) { + batch_values.set(derived, [execute_derived(derived), owner]); } + } else { + // connect disconnected deriveds if we are reading them inside an effect, + // or inside another derived that is already connected + var should_connect = + (derived.f & CONNECTED) === 0 && + !untracking && + active_reaction !== null && + (is_updating_effect || (active_reaction.f & CONNECTED) !== 0); + + var is_new = (derived.f & REACTION_RAN) === 0; + + if (is_dirty(derived)) { + if (should_connect) { + // set the flag before `update_derived`, so that the derived + // is added as a reaction to its dependencies + derived.f |= CONNECTED; + } - update_derived(derived); - } + update_derived(derived); + } - if (should_connect && !is_new) { - unfreeze_derived_effects(derived); - reconnect(derived); + if (should_connect && !is_new) { + unfreeze_derived_effects(derived); + reconnect(derived); + } } } - if (batch_values?.has(signal)) { - return batch_values.get(signal); + if (batch_values !== null) { + var override = batch_values.get(signal); + + if (override !== undefined) { + // if we're seeing another live batch's pre-write world, it must + // re-run us with the real values when it commits + var override_owner = override[1]; + + if (override_owner !== null && active_reaction !== null && !untracking) { + while (override_owner.merged_into !== null) override_owner = override_owner.merged_into; + override_owner.stale_readers.add(active_reaction); + } + + return override[0]; + } } if ((signal.f & ERROR_VALUE) !== 0) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js index 53cceb9d54..599834c733 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js @@ -13,9 +13,12 @@ export default test({ await tick(); increment.click(); await tick(); - middle.click(); // resolve the second increment which will make the if block go away and the first batch discarded + // all three increments write `a` and therefore share the async work — + // they are merged into a single batch in which the first two in-flight + // runs are superseded, so resolving the second one does nothing + middle.click(); await tick(); - assert.htmlEqual(div.innerHTML, '2 2'); + assert.htmlEqual(div.innerHTML, '0 0 0'); shift.click(); await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js index 76a2032c7a..3266280e4b 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js @@ -25,9 +25,12 @@ export default test({ assert.htmlEqual(target.innerHTML, `

0

`); assert.equal(input.value, '2'); + // both edits write `count` and therefore share the async work — they are + // merged into a single batch, in which the first in-flight run is + // superseded. Only resolving the final run commits shift.click(); await tick(); - assert.htmlEqual(target.innerHTML, `

1

`); + assert.htmlEqual(target.innerHTML, `

0

`); assert.equal(input.value, '2'); shift.click(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/_config.js index 7fddca0d58..c9cf1e4c69 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/_config.js @@ -47,6 +47,9 @@ export default test({ ); assert.equal(select.value, 'one'); + // both selections write `value` and therefore share the async work — + // they are merged into a single batch, in which the first in-flight + // run is superseded. Only resolving the final run commits shift.click(); await tick(); assert.htmlEqual( @@ -58,7 +61,7 @@ export default test({ -

three

+

two

` ); assert.equal(select.value, 'one'); diff --git a/packages/svelte/tests/runtime-runes/samples/async-child-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-child-effect/_config.js index 325cb1dcd6..25a8eafd34 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-child-effect/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-child-effect/_config.js @@ -26,6 +26,9 @@ export default test({ flushSync(() => button2.click()); flushSync(() => button2.click()); + // both updates write `input` and therefore share the async work — they + // are merged into a single batch, in which the first in-flight run is + // superseded. Only resolving the final run commits button1.click(); await tick(); @@ -34,8 +37,8 @@ export default test({ ` -

AA

-

aa

+

A

+

a

` ); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js index 5557c7ebbb..3be0aa28ac 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-effect-overlap/_config.js @@ -21,19 +21,19 @@ export default test({ await tick(); assert.htmlEqual(target.innerHTML, `${buttons}

a

a

aa

1

`); - // resolve the newer (b) batch first. Committing it must not commit the - // still-pending `a` batch, whose async work has not completed — `a` must - // still read 'a', and the unrelated `c` update must not be blocked + // the two batches share the awaited `a + b` expression, so they were + // merged into one, superseding the first in-flight run. Resolving the + // re-run ('bb', which pop reaches first) commits the combined state, + // including the `c` write from the $effect during the flush phase pop.click(); await tick(); - assert.htmlEqual(target.innerHTML, `${buttons}

a

b

ab

2

`); + assert.htmlEqual(target.innerHTML, `${buttons}

b

b

bb

2

`); - // stale promise from the `a` batch's first run — resolving it does nothing + // stale promises from the superseded runs — resolving them does nothing shift.click(); await tick(); - assert.htmlEqual(target.innerHTML, `${buttons}

a

b

ab

2

`); + assert.htmlEqual(target.innerHTML, `${buttons}

b

b

bb

2

`); - // the `a` batch's re-run await ('bb') resolves — everything is committed shift.click(); await tick(); assert.htmlEqual(target.innerHTML, `${buttons}

b

b

bb

2

`); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/_config.js index 016c311f98..d6473f6db4 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/_config.js @@ -23,11 +23,14 @@ export default test({ flushSync(() => increment.click()); } + // all four updates write `count` and therefore share the async work — + // they are merged into a single batch, in which the first three + // in-flight runs are superseded. Only resolving the final run commits for (let i = 1; i < 5; i += 1) { shift.click(); await tick(); - assert.equal(p.innerHTML, `${i}: ${Math.min(i, 3)}`); + assert.equal(p.innerHTML, i < 4 ? '0: 0' : '4: 3'); } } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js index c4efa873bf..c0f1214334 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-each-preserve-pending/_config.js @@ -23,6 +23,9 @@ export default test({ ` ); + // the three updates all write `values` and therefore share the each + // block — they are merged into a single batch, which only commits + // (revealing all items at once) when all of its async work has settled shift.click(); await tick(); assert.htmlEqual( @@ -31,7 +34,6 @@ export default test({

1

-

2

` ); @@ -43,8 +45,6 @@ export default test({

1

-

2

-

3

` ); diff --git a/packages/svelte/tests/runtime-runes/samples/async-if/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if/_config.js index ef119d601d..2a6031a83e 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-if/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-if/_config.js @@ -23,11 +23,14 @@ export default test({ f.click(); await tick(); + // all three updates write `condition` and therefore share the async + // effect — they are merged into a single batch, in which the first two + // in-flight runs are superseded. Only resolving the final run commits shift.click(); await tick(); assert.htmlEqual( target.innerHTML, - '

no

' + '

yes

' ); shift.click(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js index a65c8a6fba..7ea372165a 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js @@ -13,11 +13,14 @@ export default test({ ' 0' ); + // `b` is only read by template leaves, so the second update doesn't + // entangle with the pending batch — it commits immediately, while the + // pending batch's `a` is still held back b.click(); await tick(); assert.htmlEqual( target.innerHTML, - ' 0' + ' 0' ); resolve.click(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/_config.js index cc7b2756fa..7375be9f6e 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/_config.js @@ -15,11 +15,15 @@ export default test({ flushSync(() => a.click()); flushSync(() => b.click()); + // both updates share the awaited expression, so the batches are merged — + // the first in-flight run is superseded, and resolving the latest run + // (which pop reaches first) commits the combined state pop.click(); await tick(); - assert.htmlEqual(p.innerHTML, '1 + 3 = 4'); + assert.htmlEqual(p.innerHTML, '2 + 3 = 5'); + // resolving the superseded run does nothing pop.click(); await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-1/_config.js index 03d8e49b6f..c1708b525c 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-1/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-1/_config.js @@ -67,7 +67,7 @@ export default test({ assert.htmlEqual( target.innerHTML, ` - a 1 | b 1 | c 0 | d 0 + a 0 | b 0 | c 0 | d 0 @@ -81,7 +81,7 @@ export default test({ assert.htmlEqual( target.innerHTML, ` - a 2 | b 1 | c 1 | d 0 + a 0 | b 0 | c 0 | d 0 diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js index d03f3cfbb8..9fbf81b111 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-5/_config.js @@ -52,7 +52,7 @@ export default test({ assert.htmlEqual( target.innerHTML, ` - a 0 | b 0 | c 1 | d 1 + a 0 | b 0 | c 0 | d 0 diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js index b1bf53a2fc..08e2596091 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-7/_config.js @@ -52,7 +52,7 @@ export default test({ assert.htmlEqual( target.innerHTML, ` - a 1 | b 2 | c 0 | d 2 + a 1 | b 2 | c 1 | d 3 @@ -65,7 +65,7 @@ export default test({ assert.htmlEqual( target.innerHTML, ` - a 1 | b 2 | c 0 | d 2 + a 1 | b 2 | c 1 | d 3 diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js index 2dad5babea..ac30a310f0 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js @@ -30,6 +30,9 @@ export default test({ ` ); + // the three updates all write `values` and therefore share the each + // blocks — they are merged into a single batch, which only commits + // (revealing all items at once) when all of its async work has settled shift.click(); await tick(); shift.click(); @@ -40,14 +43,12 @@ export default test({ -

pending=4 values.length=2 values=[1,2]

+

pending=4 values.length=1 values=[1]

not keyed:
1
-
2
keyed:
1
-
2
` ); @@ -62,16 +63,12 @@ export default test({ -

pending=2 values.length=3 values=[1,2,3]

+

pending=2 values.length=1 values=[1]

not keyed:
1
-
2
-
3
keyed:
1
-
2
-
3
` ); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js index 5b951d6c49..9761c198ef 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js @@ -21,9 +21,12 @@ export default test({ await tick(); assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + // the two batches share the awaited `a + b` expression, so they were + // merged into one — resolving the superseded first run does nothing, + // and the combined state commits once all async work has settled pop.click(); await tick(); - assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); shift.click(); await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js index a6f1833d67..5cb6045f3b 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js @@ -19,11 +19,12 @@ export default test({ await tick(); assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); - // Check that the first batch can still resolve before the second even if one of its async values - // is already superseeded (but the subsequent batch as a whole is still pending). + // The two batches share the awaited `a + b` expression, so they were + // merged into one — resolving the superseded first run does nothing, + // and the combined state commits once all async work has settled shift_1.click(); await tick(); - assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); shift_1.click(); await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js index f84228ec14..624e7dfc43 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js @@ -21,13 +21,16 @@ export default test({ await tick(); assert.htmlEqual(target.innerHTML, `

0

`); + // the three updates all write `count` and therefore share the async + // work — they are merged into a single batch, in which the first two + // in-flight runs are superseded. Only resolving the final run commits shift.click(); await tick(); - assert.htmlEqual(target.innerHTML, `

1

`); + assert.htmlEqual(target.innerHTML, `

0

`); shift.click(); await tick(); - assert.htmlEqual(target.innerHTML, `

2

`); + assert.htmlEqual(target.innerHTML, `

0

`); shift.click(); await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js index e8f16ade3c..a114a6afe9 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-5/_config.js @@ -34,6 +34,10 @@ export default test({ ` ); + // committing the fork wrote `x`, which the pending y++ batch's async + // work depends on — the two are entangled into a single batch (whose + // async work was re-run with the committed `x`), so nothing is + // committed until all of that work has settled resolve.click(); await tick(); assert.htmlEqual( @@ -44,12 +48,6 @@ export default test({
- world - "world" - world - world - world - "world" ` );