From 1cdc1709d4deddb443ae0690f6dccb96ecc983f3 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 25 Sep 2026 10:04:16 +0200 Subject: [PATCH] fix: robustify async reactivity system (pt2) --- .../svelte/src/internal/client/constants.js | 2 + .../svelte/src/internal/client/dev/tracing.js | 2 +- .../internal/client/dom/blocks/branches.js | 21 +- .../src/internal/client/dom/operations.js | 11 +- .../src/internal/client/reactivity/batch.js | 260 ++++++++++++++++-- .../internal/client/reactivity/deriveds.js | 28 +- .../src/internal/client/reactivity/effects.js | 7 +- .../src/internal/client/reactivity/sources.js | 5 +- .../src/internal/client/reactivity/types.d.ts | 5 +- .../svelte/src/internal/client/runtime.js | 7 +- .../async-fork-async-effect-replay/_config.js | 41 +++ .../main.svelte | 42 +++ .../async-fork-branch-merge/_config.js | 35 +++ .../async-fork-branch-merge/main.svelte | 25 ++ .../async-fork-branch-update/_config.js | 52 ++++ .../async-fork-branch-update/main.svelte | 22 ++ .../_config.js | 21 ++ .../main.svelte | 17 ++ .../async-fork-commit-empty/_config.js | 22 ++ .../async-fork-commit-empty/main.svelte | 14 + .../async-fork-commit-overtaken/_config.js | 42 +++ .../async-fork-commit-overtaken/main.svelte | 20 ++ .../_config.js | 43 +++ .../main.svelte | 20 ++ .../_config.js | 38 +++ .../main.svelte | 48 ++++ .../async-fork-effect-overlap-1/_config.js | 38 +++ .../async-fork-effect-overlap-1/main.svelte | 23 ++ .../async-fork-effect-overlap-2/_config.js | 34 +++ .../async-fork-effect-overlap-2/main.svelte | 23 ++ .../_config.js | 48 ++++ .../main.svelte | 29 ++ .../_config.js | 41 +++ .../main.svelte | 27 ++ .../_config.js | 32 +++ .../main.svelte | 22 ++ .../async-fork-nested-branch/Child.svelte | 8 + .../async-fork-nested-branch/_config.js | 79 ++++++ .../async-fork-nested-branch/main.svelte | 36 +++ .../samples/async-fork-no-rerun/_config.js | 18 ++ .../samples/async-fork-no-rerun/main.svelte | 12 + .../_config.js | 42 +++ .../main.svelte | 43 +++ .../_config.js | 42 +++ .../main.svelte | 20 ++ .../Child.svelte | 8 + .../async-fork-uninitialized-debug/_config.js | 50 ++++ .../main.svelte | 20 ++ .../samples/async-fork-write-back/_config.js | 17 ++ .../samples/async-fork-write-back/main.svelte | 20 ++ .../async-overlap-multiple-fork-3/_config.js | 1 - .../_config.js | 1 - .../async-state-new-branch-fork-1/_config.js | 12 +- .../async-state-new-branch-fork-4/_config.js | 1 - .../async-state-new-branch-fork-5/_config.js | 7 - 55 files changed, 1524 insertions(+), 80 deletions(-) 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-update/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/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-nested-branch-static/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/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-fork-write-back/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-write-back/main.svelte diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index b086bfedff..586dd30b93 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -54,6 +54,8 @@ export const EFFECT_OFFSCREEN = 1 << 25; // Flags used for async export const REACTION_IS_UPDATING = 1 << 21; export const ASYNC = 1 << 22; +/** Set on branch effects that only exist for fork batches */ +export const FORK_ONLY_BRANCH = 1 << 23; export const ERROR_VALUE = 1 << 23; diff --git a/packages/svelte/src/internal/client/dev/tracing.js b/packages/svelte/src/internal/client/dev/tracing.js index 9fea8175bd..7ad4ca40af 100644 --- a/packages/svelte/src/internal/client/dev/tracing.js +++ b/packages/svelte/src/internal/client/dev/tracing.js @@ -1,4 +1,4 @@ -/** @import { Derived, Reaction, Value } from '#client' */ +/** @import { Derived, Reaction, Source, Value } from '#client' */ import { UNINITIALIZED } from '../../../constants.js'; import { snapshot } from '../../shared/clone.js'; import { DERIVED, ASYNC, PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 272068a253..ee4303b7ae 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -7,7 +7,7 @@ import { pause_effect, resume_effect } from '../../reactivity/effects.js'; -import { HMR_ANCHOR } from '../../constants.js'; +import { FORK_ONLY_BRANCH, HMR_ANCHOR } from '../../constants.js'; import { hydrate_node, hydrating } from '../hydration.js'; import { create_text, should_defer_append } from '../operations.js'; import { DEV } from 'esm-env'; @@ -188,6 +188,7 @@ export class BranchManager { ensure(key, fn) { var batch = /** @type {Batch} */ (current_batch); var defer = should_defer_append(); + var first = false; // Re-evaluating in the surviving batch supersedes selections made before a merge, // even though those batches originally had newer IDs and still have commit callbacks. @@ -201,16 +202,23 @@ export class BranchManager { } if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) { + first = true; + if (defer) { var fragment = document.createDocumentFragment(); var target = create_text(); fragment.append(target); + const b = branch(() => fn(target)); this.#offscreen.set(key, { - effect: branch(() => fn(target)), + effect: b, fragment }); + + if (batch.is_fork) { + b.f ^= FORK_ONLY_BRANCH; + } } else { this.#onscreen.set( key, @@ -221,6 +229,15 @@ export class BranchManager { this.#batches.set(batch, key); + const offscreen = this.#offscreen.get(key); + if (offscreen && offscreen.effect.f & FORK_ONLY_BRANCH) { + if (batch.is_fork) { + batch.unskip_effect(offscreen.effect, !first); + } else { + offscreen.effect.f ^= FORK_ONLY_BRANCH; + } + } + if (defer) { for (const [k, effect] of this.#onscreen) { if (k === key) { diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 21b5ee4ecb..05b3fa4f8a 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -3,7 +3,7 @@ import { hydrate_node, hydrating, reset, set_hydrate_node } from './hydration.js import { DEV } from 'esm-env'; import { init_array_prototype_warnings } from '../dev/equality.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js'; -import { active_effect } from '../runtime.js'; +import { active_effect, new_deps, skipped_deps } from '../runtime.js'; import { async_mode_flag } from '../../flags/index.js'; import { ATTRIBUTES_CACHE, @@ -13,7 +13,7 @@ import { TEXT_CACHE, TEXT_NODE } from '#client/constants'; -import { eager_block_effects } from '../reactivity/batch.js'; +import { current_batch, eager_block_effects } from '../reactivity/batch.js'; import { NAMESPACE_HTML } from '../../../constants.js'; // export these for reference in the compiled code, making global name deduplication unnecessary @@ -249,7 +249,12 @@ export function should_defer_append() { if (eager_block_effects !== null) return false; var flags = /** @type {Effect} */ (active_effect).f; - return (flags & REACTION_RAN) !== 0; + var ran = (flags & REACTION_RAN) !== 0; + if (ran || !current_batch?.is_fork) return ran; + // In a fork we generally want to defer the append, unless this is the first run + // and that run is terminal, i.e. there are no deps so the e.g. if block can never + // rerun, which means it can never end up in commit callbacks for other batches. + return new_deps !== null || skipped_deps !== 0; } /** diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index c60bf8bbda..c9b7777da6 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -17,7 +17,8 @@ import { ERROR_VALUE, MANAGED_EFFECT, REACTION_RAN, - DESTROYING + DESTROYING, + FORK_ONLY_BRANCH } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; @@ -26,7 +27,8 @@ import { get, increment_write_version, is_dirty, - update_effect + update_effect, + write_version } from '../runtime.js'; import * as e from '../errors.js'; import { flush_tasks, queue_micro_task } from '../dom/task.js'; @@ -158,6 +160,9 @@ export class Batch { return (this.#stale_readers ??= new Set()); } + /** @type {Set} */ + seen_effects = new Set(); + /** @type {Batch | null} */ prev = null; @@ -275,17 +280,18 @@ export class Batch { #skipped_branches = new Map(); /** - * @type {Set | null} + * @type {Map | null} */ #unskipped_branches = null; /** * Inverse of #skipped_branches which we need to tell prior batches to unskip them when committing. + * `true` indicates that this branch is new to the eyes of this fork but was already created before. * Lazily initialized for performance reasons. - * @type {Set} + * @type {Map} */ get unskipped_branches() { - return (this.#unskipped_branches ??= new Set()); + return (this.#unskipped_branches ??= new Map()); } is_fork = false; @@ -295,15 +301,17 @@ export class Batch { #decrement_queued = false; constructor() { - if (last_batch === null) { - first_batch = last_batch = this; - } else { - last_batch.next = this; - this.prev = last_batch; + // Put the new batch before the first forked batch + let batch = first_batch; + while (batch && !batch.is_fork) { + batch = batch.next; } - last_batch = this; - this.linked = true; + this.insert_before(batch); + while (batch) { + batch.id = uid++; + batch = batch.next; + } } #is_deferred() { @@ -346,8 +354,9 @@ export class Batch { * Remove an effect from the #skipped_branches map and reschedule * any tracked dirty/maybe_dirty child effects * @param {Effect} effect + * @param {boolean} is_fork_init */ - unskip_effect(effect) { + unskip_effect(effect, is_fork_init = false) { var tracked = this.#skipped_branches.get(effect); if (tracked) { this.#skipped_branches.delete(effect); @@ -362,7 +371,7 @@ export class Batch { this.schedule(e); } } - this.unskipped_branches.add(effect); + if (!this.unskipped_branches.has(effect)) this.unskipped_branches.set(effect, is_fork_init); } /** @@ -569,14 +578,42 @@ export class Batch { root.f ^= CLEAN; var effect = root.first; + var all_dirty = null; while (effect !== null) { + if (all_dirty) { + if (effect.f & CLEAN) effect.f ^= CLEAN; + if ((effect.f & DIRTY) === 0) effect.f |= MAYBE_DIRTY; + } + var flags = effect.f; 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); + if ((flags & FORK_ONLY_BRANCH) !== 0) { + var first_time = this.unskipped_branches.get(effect); + + if (first_time === undefined) { + skip = true; + this.skip_effect(effect); + reset_branch( + effect, + /** @type {{d: Effect[], m: Effect[]}} */ (this.#skipped_branches.get(effect)) + ); + } else if (first_time) { + // We're seeing a fork-only branch for the first time in another fork. We need to traverse + // all effects inside it (they're all marked MAYBE_DIRTY). This is necessary because + // dependencies of the effects inside could've updated in the real world since the last time this branch ran. + // TODO this can overfire, maybe there's a way to detect which sources actually changed. + this.unskipped_branches.set(effect, false); + all_dirty ??= effect; + if (effect.f & CLEAN) effect.f ^= CLEAN; + skip = false; + } + } + if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; @@ -584,8 +621,11 @@ export class Batch { effects.push(effect); } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) { render_effects.push(effect); - } else if (is_dirty(effect)) { - update_effect(effect); + } else { + this.seen_effects.add(effect); + if (is_dirty(effect)) { + update_effect(effect); + } } var child = effect.first; @@ -605,6 +645,8 @@ export class Batch { } effect = effect.parent; + + if (effect === all_dirty) all_dirty = null; } } } @@ -621,6 +663,51 @@ export class Batch { return null; } + /** + * Mark all reactive trees leading to block/async effects that (indirectly) depend on `value` + * @param {Value} value + * @param {number} status + * @param {boolean} not_yet - whether to mark effects that have not yet run, as opposed to those that have already run + */ + mark(value, status, not_yet = false) { + var reactions = value.reactions; + if (reactions === null) return false; + + let marked = false; + + for (const reaction of reactions) { + var flags = reaction.f; + + if ((flags & DERIVED) !== 0) { + var derived = /** @type {Derived} */ (reaction); + + // deriveds are traversed regardless of their status and only marked + // if something downstream was marked, so that we don't dirty deriveds needlessly + if (this.mark(derived, MAYBE_DIRTY, not_yet)) { + set_signal_status(derived, status); + marked = true; + } + } else { + var effect = /** @type {Effect} */ (reaction); + + if ( + not_yet + ? !this.seen_effects.has(effect) && !this.#dirty_reactions.has(effect) + : (flags & (ASYNC | BLOCK_EFFECT)) === 0 || this.seen_effects.has(effect) + ) { + if (this.#dirty_reactions.get(effect) === MAYBE_DIRTY) { + this.#dirty_reactions.delete(effect); + } + set_signal_status(effect, status); + this.schedule(effect); + marked = true; + } + } + } + + return marked; + } + /** * @param {Batch} batch */ @@ -796,12 +883,60 @@ export class Batch { } for (let batch = first_batch; batch !== null && !this.is_eager; batch = batch.next) { - if (!batch.is_fork && batch.id < this.id && batch.current.has(source)) { + if (batch.id < this.id && batch.current.has(source)) { this.dependent.add(batch); } + + if (batch.is_fork && is_latest_value) { + this.notify_fork(batch, source, is_derived, value); + } } } + /** + * Tell a fork batch that a source has been updated. Will delete that source from the fork, + * discarding it if it has no other sources left, and rerunning it else with the new value. + * @param {Batch} batch A fork + * @param {Value} source + * @param {boolean} is_derived + * @param {any} value + */ + notify_fork(batch, source, is_derived, value) { + const current = batch.current.get(source); + batch.current.delete(source); + + if ([...batch.current.values()].every((value) => value.is_derived)) { + // The real world has overtaken every write of this fork, so it is obsolete. Discard it + // right away (its speculative branches must not be adopted by anyone), and empty + // `current` so that `commit()` can tell this apart from a user-initiated discard + batch.current.clear(); + batch.discard(); + } else { + if (current && current.v !== value) batch.current.set(source, current); + if ( + !is_derived && + (!current || current.v !== value) && + ((source.f & ASYNC) === 0 || + // If the fork ran an async effect, its pending/resolved result belongs to the + // fork. Revalidate it when its inputs change, not when another batch resolves + // the same expression with a different view of those inputs. + !batch.#stale_effects?.has(/** @type {Effect} */ (/** @type {Source} */ (source).e))) + ) { + batch.current.delete(source); + batch.queue_revalidation(source); + } + } + } + + /** @param {Value} source */ + queue_revalidation(source) { + queue_micro_task(() => { + if (this.linked && this.mark(source, DIRTY)) { + this.flush(); + } + }); + } + /** * Activate batch - could be merged into another batch in the meantime, * in which case that other batch becomes the active batch. @@ -1051,6 +1186,21 @@ export class Batch { this.#scheduled.push(effect); } + /** @param {Batch | null} next `null` appends to the end of the list */ + insert_before(next) { + this.#unlink(); + this.prev = next === null ? last_batch : next.prev; + this.next = next; + + if (this.prev === null) first_batch = this; + else this.prev.next = this; + + if (next === null) last_batch = this; + else next.prev = this; + + this.linked = true; + } + #unlink() { // #merge calls #unlink, discard later on does it again - prevent // running it multiple times to not corrupt the linked list @@ -1304,7 +1454,7 @@ export function eager(fn) { get(version); eager_effect(() => { - if (initial) { + if (initial && !current_batch?.is_fork) { // the first time this runs, we create an eager effect // that will run eagerly whenever the expression changes var previous_batch_values = batch_values; @@ -1425,6 +1575,15 @@ export function fork(fn) { return; } + if (batch.current.size === 0) { + // Nothing to commit: either the fork never wrote anything (e.g. it assigned a value + // that was already current), or the real world has since written to every source + // it did write to and the fork was discarded as obsolete (see `notify_fork`) + committed = true; + batch.discard(); + return; + } + if (!batch.linked) { e.fork_discarded(); } @@ -1433,11 +1592,44 @@ export function fork(fn) { batch.is_fork = false; - // apply changes and update write versions so deriveds see the change + // Keep IDs in order, then move the batch before all remaining forks + let before = batch; + while (before.prev?.is_fork) { + const prev = before.prev; + const id = batch.id; + batch.id = prev.id; + prev.id = id; + before = prev; + } + if (before !== batch) batch.insert_before(before); + + // Apply changes and update write versions so deriveds see the change. Everything still + // in `batch.current` at this point is the latest value: sources that the real world has + // written to in the meantime were removed from the fork via `notify_fork`, while + // async results are kept up to date by revalidating their producers when inputs change. + // We use fresh versions rather than the fork-time `content.wv`, because the real world + // may have run reactions since then whose versions would otherwise outrank them. for (var [source, content] of batch.current) { + var changed = source.v !== content.v; source.v = content.v; - content.wv = source.wv = increment_write_version(); + + if (!content.is_derived) { + content.wv = source.wv = increment_write_version(); + // dirty those effects the fork did not see yet, e.g. because a later batch created new branches + batch.mark(source, MAYBE_DIRTY, true); + } else if (changed) { + // A derived that was evaluated inside the fork: bump its version too, so that reactions + // which read the (then still old) real value _after_ the fork evaluated it — and which are + // therefore not in `stale_effects` — see a newer dependency version and re-run. + content.wv = source.wv = increment_write_version(); + } + } + + // All the block/async effects the fork executed are now guaranteed to be up to date + for (const effect of batch.stale_effects.keys()) { + effect.wv = write_version; } + batch.stale_effects.clear(); // trigger any `$state.eager(...)` expressions with the new state. // eager effects don't get scheduled like other effects, so we @@ -1456,17 +1648,31 @@ export function fork(fn) { flush_eager_effects(); }); + // Promote fork-only branches to the real world + for (const e of batch.unskipped_branches.keys()) { + if (e.f & FORK_ONLY_BRANCH) { + e.f ^= FORK_ONLY_BRANCH; + } + } + batch.flush(); + + // Other forks might need to rerun now with the updated state. + let next_batch = batch.next; + while (next_batch) { + for (const [source, current] of batch.current) { + if (next_batch.current.has(source)) { + batch.notify_fork(next_batch, source, current.is_derived, current.v); + } else if (!current.is_derived) { + next_batch.queue_revalidation(source); + } + } + next_batch = next_batch.next; + } + 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 edf0474cc8..144e883a77 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -121,6 +121,10 @@ export function async_derived(fn, label, location) { var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); var signal = source(/** @type {V} */ (UNINITIALIZED)); + // Besides prod-logic this also helps in DEV to let this be printed + // as a derived when using `$inspect.trace()` + signal.f |= ASYNC; + if (DEV) signal.label = label ?? fn.toString(); // only suspend in async deriveds created on initialisation @@ -129,7 +133,7 @@ export function async_derived(fn, label, location) { /** @type {Set>>} */ var deferreds = new Set(); - async_effect(() => { + signal.e = async_effect(() => { var effect = /** @type {Effect} */ (active_effect); if (DEV) { @@ -269,12 +273,6 @@ export function async_derived(fn, label, location) { } }); - if (DEV) { - // add a flag that lets this be printed as a derived - // when using `$inspect.trace()` - signal.f |= ASYNC; - } - return new Promise((fulfil) => { /** @param {Promise} p */ function next(p) { @@ -404,18 +402,9 @@ export function update_derived(derived) { var value = execute_derived(derived); if (!derived.equals(value)) { - // 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) { - // bump the write version so that reactions reading the (then still old) - // real value re-run once the fork is discarded — its sources are bumped - // on discard, which must outrank this version to trigger recomputation - derived.wv = increment_write_version(); - } else if (current_batch !== null || previous_batch !== null) { - // `capture` decides whether the underlying value is updated (it isn't - // if a later batch holds a newer value) and records it in the batch either way. + if (current_batch !== null || previous_batch !== null) { + // `capture` decides whether the underlying value is updated (it isn't in a fork, + // or if a later batch holds a newer value) and records it in the batch either way. // 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 @@ -458,6 +447,7 @@ export function update_derived(derived) { batch_values?.set(derived, value); } var is_latest_value = + !current_batch?.is_fork && value === derived.v && !derived.deps?.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v); diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 7f52ce3b8d..f0ee2cfbf9 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 } from './batch.js'; +import { Batch, collected_effects, current_batch } 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'; @@ -368,7 +368,9 @@ export function legacy_pre_effect_reset() { * @returns {Effect} */ export function async_effect(fn) { - return create_effect(ASYNC | EFFECT_PRESERVED, fn); + const effect = create_effect(ASYNC | EFFECT_PRESERVED, fn); + current_batch?.seen_effects.add(effect); + return effect; } /** @@ -415,6 +417,7 @@ export function block(fn, flags = 0) { if (DEV) { effect.dev_stack = dev_stack; } + current_batch?.seen_effects.add(effect); return effect; } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index d3f2b51058..b001702093 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -71,14 +71,15 @@ export function set_eager_effects_deferred() { */ // TODO rename this to `state` throughout the codebase export function source(v, stack) { - /** @type {Source} */ + /** @type {Source} */ var signal = { f: 0, v, reactions: null, equals, rv: 0, - wv: 0 + wv: 0, + e: null }; if (DEV && tracing_mode_flag) { diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 0ee8570c3d..8e9b4e7624 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -100,7 +100,10 @@ export interface Effect extends Reaction { dev_stack?: DevStackEntry | null; } -export type Source = Value; +export interface Source extends Value { + /** Only set for ASYNC signals - the corresponding effect that writes to this source */ + e: Effect | null; +} export type MaybeSource = T | Source; diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 14643b3224..fefc70d785 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -502,9 +502,10 @@ export function update_effect(effect) { var own_batch = previous_batch ?? current_batch; // can be null inside flush_eager_effects var is_latest_value = own_batch === null || - batch_values === null || - effect.deps === null || - !effect.deps.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v); + (!own_batch.is_fork && + (batch_values === null || + effect.deps === null || + !effect.deps.some((d) => batch_values?.has(d) && batch_values.get(d) !== d.v))); if (is_latest_value) { effect.wv = write_version; diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js new file mode 100644 index 0000000000..165ac7e2c8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js @@ -0,0 +1,41 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target, instance }) { + const [fork_button, update, resolve, discard] = target.querySelectorAll('button'); + + fork_button.click(); + await tick(); + assert.equal(instance.get_calls(), 1); + assert.htmlEqual(target.innerHTML, buttons); + + // Transfer an invalidation into the fork while its async work is pending. + update.click(); + await tick(); + assert.equal(instance.get_calls(), 1); // can also be 2 at this point, would also be ok + + try { + resolve.click(); + await tick(); + assert.equal(instance.get_calls(), 1); + + // Completing the replacement must not replay the same invalidation. + resolve.click(); + await tick(); + assert.equal(instance.get_calls(), 1); + } finally { + discard.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, buttons); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte new file mode 100644 index 0000000000..814980d99b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte @@ -0,0 +1,42 @@ + + + + + + + +{#if show} + + {#snippet pending()}loading{/snippet} +

{await load(searchParams.value)}

+
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js new file mode 100644 index 0000000000..caf93e9489 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js @@ -0,0 +1,35 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, increment, commit, merge, resolve] = target.querySelectorAll('button'); + + preload.click(); + flushSync(() => increment.click()); + assert.deepEqual(logs, [0, 1]); // [0] would also be ok; the 1 must be done in the context of the fork (impossible to assert so you gotta check manually) + + commit.click(); + assert.deepEqual(logs, [0, 1]); + + flushSync(() => merge.click()); + await tick(); + assert.deepEqual(logs, [0, 1]); + assert.htmlEqual(target.innerHTML, buttons); + + // Resolve the obsolete request for 0, then the existing request for 1. + resolve.click(); + resolve.click(); + await tick(); + assert.deepEqual(logs, [0, 1]); + assert.htmlEqual(target.innerHTML, `${buttons}

1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte new file mode 100644 index 0000000000..1166cc0a03 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte @@ -0,0 +1,25 @@ + + + + + + + + +{#if show && gate > 0} +

{await load(count)}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js new file mode 100644 index 0000000000..6ab63f7693 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js @@ -0,0 +1,52 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + + + +`; + +export default test({ + async test({ assert, target }) { + const [preload, increment, commit, reveal, discard, preload_second, commit_second, reset] = + target.querySelectorAll('button'); + + for (const mode of ['commit', 'reveal', 'second-fork']) { + try { + preload.click(); + await tick(); + increment.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + + if (mode === 'commit') { + commit.click(); + await tick(); + } else if (mode === 'reveal') { + flushSync(() => reveal.click()); + discard.click(); + } else { + preload_second.click(); + discard.click(); + commit_second.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, `${buttons}

1 2

`); + flushSync(() => increment.click()); + assert.htmlEqual(target.innerHTML, `${buttons}

2 4

`); + flushSync(() => reset.click()); + assert.htmlEqual(target.innerHTML, buttons); + } catch (e) { + /** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`; + throw e; + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte new file mode 100644 index 0000000000..a0e98c1c4b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + + +{#if show} +

{count} {doubled}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/_config.js new file mode 100644 index 0000000000..7fe22437f6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ``; + +export default test({ + async test({ assert, target }) { + const [fork, show, commit] = target.querySelectorAll('button'); + + fork.click(); + await tick(); + + show.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}two

2

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/main.svelte new file mode 100644 index 0000000000..9b425a216d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-derived-new-reader/main.svelte @@ -0,0 +1,17 @@ + + + + + + +{#if d2 === 2}two{/if} +{#if show} +

{d2}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/_config.js new file mode 100644 index 0000000000..9e17498d26 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/_config.js @@ -0,0 +1,22 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ``; + +// If a fork is auto-discarded it should not throw on user-commit. +export default test({ + mode: ['client'], + async test({ assert, target }) { + const [preload, other, commit] = target.querySelectorAll('button'); + + preload.click(); + await tick(); + other.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

true 1

`); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

true 1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/main.svelte new file mode 100644 index 0000000000..b4e35f29a7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-empty/main.svelte @@ -0,0 +1,14 @@ + + + + + + +

{open} {other} {error}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/_config.js new file mode 100644 index 0000000000..0ccca0c573 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +// A fork writes `x`, then the real world writes `x` as well (with async work still pending). +// The fork's write is overtaken and it has nothing left to commit — `commit()` must resolve +// rather than throw `fork_discarded`, and the real world's value wins +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [fork, x5, commit, shift] = target.querySelectorAll('button'); + + fork.click(); // speculative: delay(10) + await tick(); + x5.click(); // real: delay(5); the fork adopts x = 5 and re-runs: delay(5) + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + + shift.click(); // the fork's obsolete delay(10) + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + + shift.click(); // the real delay(5) + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

5

`); + + shift.click(); // the fork's delay(5), rejected when the fork was cleaned up + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

5

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/main.svelte new file mode 100644 index 0000000000..c6c6ba9c1b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-overtaken/main.svelte @@ -0,0 +1,20 @@ + + + + + + + +

{await delay(x)} {error}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/_config.js new file mode 100644 index 0000000000..b7ace65f23 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/_config.js @@ -0,0 +1,43 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +export default test({ + async test({ assert, target }) { + await tick(); + const [forkButton, y, shift, commit] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

0

${buttons}`); + + // speculative world: x becomes 1, async expression runs with x + y = 1 + forkButton.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

${buttons}`); + + // real world: y becomes 1, async expression runs with x + y = 1 + // (computed with the pre-fork x = 0) — this run supersedes the + // fork's validation of the effect + y.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

${buttons}`); + + // commit the fork while the real batch is still pending — x = 1 is + // written, and the async expression must eventually re-run with the + // committed value, because its in-flight run used x = 0 + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

${buttons}`); + + // resolve all in-flight runs (superseded ones are no-ops) + for (let i = 0; i < 4; i += 1) { + shift.click(); + await tick(); + } + + // x = 1, y = 1 — anything else means the effect resolved with a value + // computed from stale inputs and was never re-run + assert.htmlEqual(target.innerHTML, `

2

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/main.svelte new file mode 100644 index 0000000000..3dba3d1004 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-commit-superseded-effect/main.svelte @@ -0,0 +1,20 @@ + + +

{await delay(x + y)}

+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js new file mode 100644 index 0000000000..45f2a13f23 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [speculate, update, commit, resolve, reset] = target.querySelectorAll('button'); + + for (const action of [update, commit]) { + speculate.click(); + await tick(); + logs.length = 0; + + // Discard before the queued fork revalidation runs. It must not restart + // the async effect and abort the real world's request, whether the write + // came from a normal update or another fork's commit. + action.click(); + await tick(); + resolve.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, `${buttons}

0/1

`); + assert.deepEqual(logs, ['0/1']); + + reset.click(); + await tick(); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte new file mode 100644 index 0000000000..197764c910 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-revalidation/main.svelte @@ -0,0 +1,48 @@ + + + + + + + + +

{await load(x, y)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/_config.js new file mode 100644 index 0000000000..1849a9b5ee --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [x, y, shift, pop, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + logs.length = 0; + + x.click(); + await tick(); + assert.deepEqual(logs, ['called with 1,0']); + logs.length = 0; + + y.click(); + await tick(); + assert.deepEqual(logs, ['called with 0,1', 'called with 1,1']); // if 'called with 1,1' happens a few button clicks later that would also be ok + assert.htmlEqual(p.innerHTML, '0'); + logs.length = 0; + + pop.click(); // the rerunning fork + await tick(); + pop.click(); // the first real world run + await tick(); + assert.deepEqual(logs, []); + assert.htmlEqual(p.innerHTML, '1'); + logs.length = 0; + + commit.click(); + await tick(); + assert.deepEqual(logs, []); + assert.htmlEqual(p.innerHTML, '2'); + + pop.click(); + await tick(); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/main.svelte new file mode 100644 index 0000000000..2e461cd884 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-1/main.svelte @@ -0,0 +1,23 @@ + + + + + + + + +

{await delay(console.log('called with ' + x + ',' + y), x + y)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/_config.js new file mode 100644 index 0000000000..4aeeff78b2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [x, y, shift, pop, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + logs.length = 0; + + y.click(); + await tick(); + assert.deepEqual(logs, ['called with 0,1']); + logs.length = 0; + + x.click(); + await tick(); + assert.deepEqual(logs, ['called with 1,1']); + assert.htmlEqual(p.innerHTML, '0'); + logs.length = 0; + + shift.click(); + await tick(); + assert.deepEqual(logs, []); + assert.htmlEqual(p.innerHTML, '1'); + + commit.click(); + await tick(); + pop.click(); + await tick(); + assert.deepEqual(logs, []); + assert.htmlEqual(p.innerHTML, '2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/main.svelte new file mode 100644 index 0000000000..2e461cd884 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-2/main.svelte @@ -0,0 +1,23 @@ + + + + + + + + +

{await delay(console.log('called with ' + x + ',' + y), x + y)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/_config.js new file mode 100644 index 0000000000..7dc50351cd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/_config.js @@ -0,0 +1,48 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [fork, update, pop, commit] = target.querySelectorAll('button'); + const [sum, doubled] = target.querySelectorAll('p'); + logs.length = 0; + + fork.click(); + await tick(); + assert.deepEqual(logs, ['sum 1,0']); + logs.length = 0; + + // Revalidate twice to also check that retaining async results does not + // prevent the fork from responding to genuine changes to its inputs. + for (const y of [1, 2]) { + update.click(); + await tick(); + assert.deepEqual(logs, [`sum 0,${y}`, `sum 1,${y}`]); + logs.length = 0; + + pop.click(); // resolve the fork before the real world + await tick(); + assert.deepEqual(logs, [`double ${y + 1}`]); + logs.length = 0; + + pop.click(); + await tick(); + assert.deepEqual(logs, [`double ${y}`]); + assert.htmlEqual(sum.innerHTML, String(y)); + assert.htmlEqual(doubled.innerHTML, String(y * 2)); + logs.length = 0; + } + + commit.click(); + await tick(); + assert.deepEqual(logs, []); + assert.htmlEqual(sum.innerHTML, '3'); + assert.htmlEqual(doubled.innerHTML, '6'); + + pop.click(); // the superseded first fork run + await tick(); + assert.htmlEqual(sum.innerHTML, '3'); + assert.htmlEqual(doubled.innerHTML, '6'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/main.svelte new file mode 100644 index 0000000000..a682f9e170 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-overlap-chain/main.svelte @@ -0,0 +1,29 @@ + + + + + + + +

{sum}

+

{await double(sum)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/_config.js new file mode 100644 index 0000000000..5d164fcc82 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/_config.js @@ -0,0 +1,41 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + +`; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [fork_button, resolve, commit] = target.querySelectorAll('button'); + + assert.deepEqual(logs, [[0, true]]); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + + fork_button.click(); + + await tick(); + assert.deepEqual(logs, [ + [0, true], + [2, true] + ]); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + + // `delayed` changes, but `nonnegative` stays true. The async expression has + // already consumed the fork's `doubled`, so it should not run again. + resolve.click(); + await tick(); + assert.deepEqual(logs, [ + [0, true], + [2, true] + ]); + assert.htmlEqual(target.innerHTML, `${buttons}

0

`); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/main.svelte new file mode 100644 index 0000000000..49083cc97b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/main.svelte @@ -0,0 +1,27 @@ + + + + + + +

{await load(doubled, nonnegative)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js new file mode 100644 index 0000000000..1c16112872 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target }) { + const [preload, reveal, hide, commit] = target.querySelectorAll('button'); + + preload.click(); + reveal.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}0

constant

keyed

boundary

` + ); + + hide.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}0`); + + // The remaining fork write must not resurrect its obsolete branch selection. + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte new file mode 100644 index 0000000000..13247dda7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte @@ -0,0 +1,22 @@ + + + + + + + +{other} + +{#if show} + {#if true}

constant

{/if} + {#key 1}

keyed

{/key} + +

boundary

+
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte new file mode 100644 index 0000000000..b131b926f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte @@ -0,0 +1,8 @@ + + +{#if pending}pending{/if} +

{pages}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js new file mode 100644 index 0000000000..61eb25eeb8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js @@ -0,0 +1,79 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, reveal, reveal_and_navigate, navigate, resolve, commit, discard, reset] = + target.querySelectorAll('button'); + + for (const mode of ['separate', 'together', 'pending']) { + for (const finish of [commit, discard]) { + try { + preload.click(); + + if (mode !== 'pending') { + resolve.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, ['load']); + + if (mode === 'together') { + reveal_and_navigate.click(); + } else { + reveal.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + navigate.click(); + } + + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + assert.deepEqual(logs, ['load']); + + if (mode === 'pending') { + resolve.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + } + + finish.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}
${finish === commit ? 'pending

2

' : ''}
` + ); + assert.deepEqual(logs, ['load']); + + if (finish === commit) { + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + assert.deepEqual(logs, ['load']); + } + + reset.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + logs.length = 0; + } catch (e) { + /** @type {Error} */ (e).message = + `${mode}/${finish === commit ? 'commit' : 'discard'}: ${/** @type {Error} */ (e).message}`; + throw e; + } + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte new file mode 100644 index 0000000000..6d24027f05 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + +{#if outer} +
+ {#if inner} + console.log(error.message)}> + {#snippet pending()}loading{/snippet} + + + {/if} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/_config.js new file mode 100644 index 0000000000..7fb39c8a84 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/_config.js @@ -0,0 +1,18 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [change, commit] = target.querySelectorAll('button'); + + assert.deepEqual(logs, ['hi']); + + change.click(); + await tick(); + assert.deepEqual(logs, ['hi', 'hi']); + + commit.click(); + await tick(); + assert.deepEqual(logs, ['hi', 'hi']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/main.svelte new file mode 100644 index 0000000000..810e09097b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-no-rerun/main.svelte @@ -0,0 +1,12 @@ + + + + + +{#if y}hi{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js new file mode 100644 index 0000000000..b2f791a40a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + const [speculate, catch_up, update, commit, discard] = target.querySelectorAll('button'); + + speculate.click(); + await tick(); + catch_up.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + logs.length = 0; + + // All speculative writes are obsolete. Only the real world should react. + update.click(); + await tick(); + try { + assert.htmlEqual(target.innerHTML, `${buttons}

3

`); + assert.deepEqual(logs, ['1/1/1']); + + // Committing an automatically discarded fork should be a no-op and not throw, + // as the user cannot really know that something got automatically discarded. + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

3

`); + assert.deepEqual(logs, ['1/1/1', 'committed']); + } finally { + discard.click(); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte new file mode 100644 index 0000000000..eeacc2c8d1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-obsolete-multiple-sources/main.svelte @@ -0,0 +1,43 @@ + + + + + + + + +

{await load(x, y, z)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/_config.js new file mode 100644 index 0000000000..31474640ef --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/_config.js @@ -0,0 +1,42 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [forkButton, real, shift, discard] = target.querySelectorAll('button'); + + assert.deepEqual(logs, ['b 0']); + logs.length = 0; + + // speculative world: fork writes b and c, runs the async expression + forkButton.click(); + await tick(); + assert.deepEqual(logs, ['b 1']); + + // real world: b++ re-runs the async expression for real + real.click(); + await tick(); + assert.deepEqual(logs, ['b 1', 'b 1']); + + // resolve the fork's in-flight run — the fork is still speculative, + // nothing should be committed or re-run + shift.click(); + await tick(); + assert.deepEqual(logs, ['b 1', 'b 1']); + + // resolve the real run — the real batch commits b = 1. The fork's world + // value of `b` is also 1 (its own write, now also committed), so the + // fork's async expression sees unchanged inputs and should not re-run + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

1 0

' + ); + assert.deepEqual(logs, ['b 1', 'b 1']); + + discard.click(); + await tick(); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/main.svelte new file mode 100644 index 0000000000..f2a6df36d7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-revalidation-no-refetch/main.svelte @@ -0,0 +1,20 @@ + + +

{await delay(console.log(`b ${b}`), b)} {c}

+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte new file mode 100644 index 0000000000..b131b926f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte @@ -0,0 +1,8 @@ + + +{#if pending}pending{/if} +

{pages}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js new file mode 100644 index 0000000000..40a9c5e63d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js @@ -0,0 +1,50 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, navigate, commit, discard] = target.querySelectorAll('button'); + + preload.click(); + // Let the async child resolve, without committing its fork. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + // A real-world update must not evaluate the speculative child in the real world. + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + discard.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + navigate.click(); + await tick(); + preload.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}pending

2

`); + assert.deepEqual(logs, []); + + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + assert.deepEqual(logs, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte new file mode 100644 index 0000000000..43e9600df9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte @@ -0,0 +1,20 @@ + + + + + + + +{#if show} + console.log(error.message)}> + {#snippet pending()}loading{/snippet} + + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-write-back/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-write-back/_config.js new file mode 100644 index 0000000000..3d96cb58e6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-write-back/_config.js @@ -0,0 +1,17 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [create, commit] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + create.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0:0'); + + commit.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0:0'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-write-back/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-write-back/main.svelte new file mode 100644 index 0000000000..2d424c95aa --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-write-back/main.svelte @@ -0,0 +1,20 @@ + + + + + + +

{source}:{writable}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js index 61360d8dc9..fd7bc40eed 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-overlap-multiple-fork-3/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, async test({ assert, target }) { await tick(); const [a_b_fork, a_c, b_d, shift, pop, commit] = target.querySelectorAll('button'); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js index 8b7e7784de..708d90655d 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-all-combinations/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO more combinations pass on https://github.com/sveltejs/svelte/pull/17971 timeout: 20_000, async test({ assert, target }) { const [x, fork_x, y, fork_y, shift, pop, commit_x, commit_y, reset] = diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js index 737169cb91..24d6c12205 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-1/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target }) { const [x, y, shift, pop, commit] = target.querySelectorAll('button'); @@ -23,7 +22,7 @@ export default test({ ` ); - commit.click(); + commit.click(); // puts fork (x) behind y so it has to wait on y first await tick(); assert.htmlEqual( target.innerHTML, @@ -37,7 +36,7 @@ export default test({ ` ); - shift.click(); + shift.click(); // ... which is why nothing happens yet on first shift await tick(); assert.htmlEqual( target.innerHTML, @@ -47,13 +46,6 @@ export default test({ - universe - universe - "universe" - universe - universe - universe - "universe"
` ); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js index 6a6399a19e..a20f8c0ba5 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-fork-4/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works on https://github.com/sveltejs/svelte/pull/17971 async test({ assert, target }) { const [x, y, resolve, commit] = target.querySelectorAll('button'); 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 977fb281b3..4881907ade 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 @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO works with fork reconciliation async test({ assert, target }) { const [x, y, resolve, commit] = target.querySelectorAll('button'); @@ -45,12 +44,6 @@ export default test({
- world - "world" - world - world - world - "world" ` );