From d28ea11509137375ae8632180b611fc446ce262b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 15 Oct 2026 23:32:33 +0200 Subject: [PATCH] wip --- .changeset/exact-batch-versions.md | 5 + .../internal/client/dom/blocks/branches.js | 15 +- .../src/internal/client/dom/blocks/each.js | 11 + .../src/internal/client/reactivity/batch.js | 651 +++++++++++------- .../internal/client/reactivity/deriveds.js | 171 +++-- .../src/internal/client/reactivity/effects.js | 10 +- .../src/internal/client/reactivity/sources.js | 84 ++- .../src/internal/client/reactivity/types.d.ts | 24 +- .../svelte/src/internal/client/runtime.js | 455 ++++++++++-- .../Sync.svelte | 5 + .../_config.js | 28 + .../main.svelte | 33 + .../Async.svelte | 6 + .../Sync.svelte | 5 + .../_config.js | 16 + .../main.svelte | 18 + .../async-branch-commit-order/_config.js | 27 + .../async-branch-commit-order/main.svelte | 24 + .../_config.js | 35 + .../main.svelte | 23 + .../_config.js | 31 + .../main.svelte | 24 + .../async-derived-batch-cache/Child.svelte | 20 + .../async-derived-batch-cache/_config.js | 76 ++ .../async-derived-batch-cache/main.svelte | 25 + .../_config.js | 44 ++ .../main.svelte | 25 + .../_config.js | 37 + .../main.svelte | 21 + .../_config.js | 1 - .../async-derived-not-overfiring/_config.js | 1 - .../async-derived-not-underfiring/_config.js | 1 - .../_config.js | 33 + .../main.svelte | 38 + .../async-effect-resume-no-rerun/Child.svelte | 9 + .../async-effect-resume-no-rerun/_config.js | 37 + .../async-effect-resume-no-rerun/main.svelte | 29 + .../_config.js | 39 ++ .../main.svelte | 34 + .../async-equal-result-dropped/_config.js | 47 ++ .../async-equal-result-dropped/main.svelte | 36 + .../_config.js | 40 ++ .../main.svelte | 39 ++ .../async-fork-async-effect-replay/_config.js | 41 ++ .../main.svelte | 38 + .../async-fork-branch-merge/_config.js | 35 + .../async-fork-branch-merge/main.svelte | 25 + .../async-fork-branch-reveal/_config.js | 54 ++ .../async-fork-branch-reveal/main.svelte | 22 + .../_config.js | 53 ++ .../main.svelte | 25 + .../_config.js | 41 ++ .../main.svelte | 27 + .../_config.js | 50 ++ .../main.svelte | 28 + .../Child.svelte | 8 + .../async-fork-uninitialized-debug/_config.js | 50 ++ .../main.svelte | 20 + .../Child.svelte | 14 + .../_config.js | 33 + .../main.svelte | 23 + .../async-merge-branch-reselection/_config.js | 31 + .../main.svelte | 24 + .../_config.js | 49 ++ .../main.svelte | 27 + .../samples/async-stale-derived-2/_config.js | 10 +- .../_config.js | 1 - .../async-state-new-branch-fork-1/_config.js | 1 - .../_config.js | 32 + .../main.svelte | 19 + 70 files changed, 2704 insertions(+), 410 deletions(-) create mode 100644 .changeset/exact-batch-versions.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Async.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-effect-write-version/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-new-branch-unrelated-update/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-new-branch-unrelated-update/main.svelte diff --git a/.changeset/exact-batch-versions.md b/.changeset/exact-batch-versions.md new file mode 100644 index 0000000000..6907eebcbf --- /dev/null +++ b/.changeset/exact-batch-versions.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep values computed in a batch's view of the world local to that batch, and don't needlessly re-run deriveds while several batches are pending diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 141b914537..08efb3e202 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -8,6 +8,7 @@ import { resume_effect } from '../../reactivity/effects.js'; import { HMR_ANCHOR } from '../../constants.js'; +import { EFFECT_OFFSCREEN } from '#client/constants'; import { hydrate_node, hydrating } from '../hydration.js'; import { create_text, should_defer_append } from '../operations.js'; import { DEV } from 'esm-env'; @@ -107,6 +108,10 @@ export class BranchManager { // ...and append the fragment this.anchor.before(offscreen.fragment); onscreen = offscreen.effect; + + if ((onscreen.f & EFFECT_OFFSCREEN) !== 0) { + onscreen.f ^= EFFECT_OFFSCREEN; + } } } @@ -194,10 +199,12 @@ export class BranchManager { fragment.append(target); - this.#offscreen.set(key, { - effect: branch(() => fn(target)), - fragment - }); + var effect = branch(() => fn(target)); + + // until it is committed, the branch only exists for the batches that select it + effect.f |= EFFECT_OFFSCREEN; + + this.#offscreen.set(key, { effect, fragment }); } else { this.#onscreen.set( key, diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 58d59a6825..b87744c70f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -336,7 +336,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f ); if (!first_run) { + // until it is committed, the item only exists for the batches that select it item.e.f |= EFFECT_OFFSCREEN; + if (defer) batch.unskip_effect(item.e); } items.set(key, item); @@ -350,10 +352,19 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f fallback = branch(() => fallback_fn(anchor)); } else { fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text()))); + // until it is committed, the fallback only exists for the batches that select it fallback.f |= EFFECT_OFFSCREEN; } } + if (defer && fallback !== null && (fallback.f & EFFECT_OFFSCREEN) !== 0) { + if (length === 0) { + batch.unskip_effect(fallback); + } else { + batch.skip_effect(fallback); + } + } + if (length > keys.size) { if (DEV) { validate_each_keys(array, get_key); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 655dfc6598..83ab2d9f3a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1,5 +1,5 @@ /** @import { Fork } from 'svelte' */ -/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ +/** @import { BatchRecord, Derived, Effect, Reaction, Source, Value } from '#client' */ import { BLOCK_EFFECT, BRANCH_EFFECT, @@ -17,15 +17,18 @@ import { ERROR_VALUE, MANAGED_EFFECT, REACTION_RAN, - DESTROYING + DESTROYING, + EFFECT_OFFSCREEN } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; -import { deferred, define_property, includes } from '../../shared/utils.js'; +import { deferred, define_property } from '../../shared/utils.js'; import { active_reaction, get, increment_write_version, is_dirty, + next_local_version, + promote_record, update_effect } from '../runtime.js'; import * as e from '../errors.js'; @@ -36,13 +39,12 @@ import { flush_eager_effects, old_values, set_eager_effects, source, update } fr import { eager_effect, teardown, unlink_effect } from './effects.js'; import { defer_effect } from './utils.js'; import { UNINITIALIZED } from '../../../constants.js'; -import { set_signal_status } from './status.js'; +import { set_signal_status, update_derived_status } from './status.js'; import { invariant } from '../../shared/dev.js'; -import { log_effect_tree } from '../dev/debug.js'; import { OBSOLETE } from './deriveds.js'; /** @type {Batch | null} */ -let first_batch = null; +export let first_batch = null; /** @type {Batch | null} */ let last_batch = null; @@ -59,14 +61,33 @@ export let previous_batch = null; /** * When time travelling (i.e. working in one batch, while other batches * still have ongoing work), we ignore the real values of affected - * signals in favour of their values within the batch - * @type {Map | null} + * signals in favour of their values within the batch — along with their + * versions. It also caches deriveds evaluated in this view of the world + * (see `view_entry`), in which case `l` tells whether the value is the + * latest (real-world) value. + * `null` means that the real world (i.e. the latest values) applies + * @type {Map | null} */ export let batch_values = null; +/** + * The batch whose view of the world is active (see `apply`). Block and async + * effects that run while it is processed, and deriveds evaluated with values + * that are not the latest, are recorded in this batch + * @type {Batch | null} + */ +export let view_batch = null; + /** @type {Effect | null} */ let last_scheduled_effect = null; +/** + * Batches whose view of the world changed because another batch committed, and that + * therefore need to be processed again once the current flush is done (see `#commit`) + * @type {Set} + */ +var rebases = new Set(); + export let is_flushing_sync = false; let is_processing = false; @@ -112,20 +133,42 @@ export class Batch { async_deriveds = new Map(); /** - * The current values of any signals that are updated in this batch. - * Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment) + * The current values (and their versions) of any sources that are updated in this batch, + * including deriveds that were overridden via assignment. * The keys of this map are identical to `this.previous` - * @type {Map} + * @type {Map} */ current = new Map(); /** - * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place. + * The values (and their versions) of any sources that are updated in this batch _before_ those updates took place. * The keys of this map are identical to `this.current` - * @type {Map} + * @type {Map} */ previous = new Map(); + /** + * Results of reactions that belong to this batch rather than to the real world: + * deriveds evaluated in this batch's view of the world with values that are not the + * latest ones, and block/async effects that ran while this batch was processed. + * Effect records become global state when the batch commits (see `promote_record`) + * @type {Map} + */ + records = new Map(); + + /** + * True while the batch commits, i.e. while it updates the DOM and runs effects. + * Effects that run then do so for the real world + */ + committing = false; + + /** + * Earlier batches whose changes are part of this batch's view of the world, because + * they intersect with it (this batch will be merged into them, see `#merge`) + * @type {Batch[]} + */ + folded = []; + /** * When the batch is committed (and the DOM is updated), we need to remove old branches * and append new ones by calling the functions added inside (if/each/key/etc) blocks @@ -157,6 +200,12 @@ export class Batch { */ #deferred = null; + /** + * Effects that were scheduled while this batch wasn't being processed (see `schedule`) + * @type {Set} + */ + #unprocessed = new Set(); + /** * Effects that were scheduled in this batch but not yet 'resolved' into the * root effects that need to be flushed. Resolving — the upwards traversal that @@ -170,12 +219,6 @@ export class Batch { */ #scheduled = []; - /** - * Effects created while this batch was active. - * @type {Effect[]} - */ - #new_effects = []; - /** * Deferred effects (which run after async work has completed) and their status (DIRTY or MAYBE_DIRTY) * @type {Map} @@ -216,19 +259,25 @@ export class Batch { if (this.is_fork) return true; for (const effect of this.#blocking_pending.keys()) { - var e = effect; - var skipped = false; - - while (e.parent !== null) { - if (this.#skipped_branches.has(e)) { - skipped = true; - break; - } - - e = e.parent; + // pending work inside a skipped branch doesn't block, the branch is going to be destroyed + if (!this.#is_skipped(effect)) { + return true; } + } - if (!skipped) { + return false; + } + + /** + * Whether `effect` is (inside) a branch that this batch skips, or that only exists offscreen for other batches + * @param {Effect} effect + */ + #is_skipped(effect) { + for (var e = /** @type {Effect | null} */ (effect); e !== null; e = e.parent) { + if ( + this.#skipped_branches.has(e) || + ((e.f & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(e)) + ) { return true; } } @@ -251,26 +300,52 @@ export class Batch { * Remove an effect from the #skipped_branches map and reschedule * any tracked dirty/maybe_dirty child effects * @param {Effect} effect - * @param {(e: Effect) => void} callback + * @param {(e: Effect, status: number) => void} callback */ - unskip_effect(effect, callback = (e) => this.schedule(e)) { + unskip_effect(effect, callback = (e, status) => this.schedule(e, status)) { var tracked = this.#skipped_branches.get(effect); if (tracked) { this.#skipped_branches.delete(effect); for (var e of tracked.d) { - set_signal_status(e, DIRTY); - callback(e); + callback(e, DIRTY); } for (e of tracked.m) { - set_signal_status(e, MAYBE_DIRTY); - callback(e); + callback(e, MAYBE_DIRTY); } } + + if ((effect.f & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(effect)) { + // This batch adopts a branch that so far only existed offscreen (possibly for another + // batch, whose view of the world its effects ran in) — re-validate them in this batch's view + this.#revalidate_branch(effect, callback); + } + this.#unskipped_branches.add(effect); } + /** + * @param {Effect} effect + * @param {(e: Effect, status: number) => void} callback + */ + #revalidate_branch(effect, callback) { + for (var e = effect.first; e !== null; e = e.next) { + var flags = e.f; + + if ((flags & (DESTROYED | INERT)) !== 0) continue; + + // branches nested inside that exist offscreen for other batches only are not adopted + if ((flags & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(e)) continue; + + if ((flags & (BRANCH_EFFECT | ROOT_EFFECT | EAGER_EFFECT)) === 0 && e.fn !== null) { + callback(e, MAYBE_DIRTY); + } + + this.#revalidate_branch(e, callback); + } + } + /** * Convert the effects that were scheduled in this batch into the root effects * that need to be traversed, marking the path to each effect (by clearing the @@ -320,7 +395,15 @@ export class Batch { } #process() { + if (async_mode_flag && this.started) { + // Since this batch last ran, reactions may have come to depend on its writes without it + // knowing (e.g. effects created by other batches), or have run in the view of other batches. + // Re-validate them in this batch's view — this only re-runs those that are out of date + this.#revalidate(this.current.keys()); + } + this.started = true; + this.#apply_unprocessed(); if (DEV) { // track all the values that were updated during this flush, @@ -336,7 +419,7 @@ export class Batch { // causing the block to no longer be deferred. for (const [e, status] of this.#dirty_reactions) { set_signal_status(e, status); - this.schedule(e); + this.#add(e); } this.apply(); @@ -384,7 +467,8 @@ export class Batch { if (updates.length > 0) { var batch = Batch.ensure(); for (const e of updates) { - batch.schedule(e); + // these were marked during traversal already (see `mark_reactions`), unless they ran since + if ((e.f & CLEAN) === 0) batch.schedule(e, (e.f & DIRTY) !== 0 ? DIRTY : MAYBE_DIRTY); } } @@ -421,6 +505,17 @@ export class Batch { // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches. this.#dirty_reactions.clear(); + // The results of this batch are about to become the real-world state + this.committing = true; + + for (const [reaction, record] of this.records) { + if ((reaction.f & (DERIVED | DESTROYED)) === 0) { + promote_record(reaction, record); + } + } + + this.records.clear(); + // append/remove branches for (const fn of this.#commit_callbacks) fn(this); this.#commit_callbacks.clear(); @@ -430,6 +525,8 @@ export class Batch { flush_queued_effects(effects); previous_batch = null; + this.committing = false; + this.#deferred?.resolve(); var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); @@ -469,6 +566,48 @@ export class Batch { } } + /** + * Schedule the reactions that (transitively) depend on `sources` for re-validation in this batch. + * They are marked MAYBE_DIRTY, which means that they are only re-run if the exact versions + * of their dependencies differ from the ones they last saw (in this batch's view of the world) + * @param {Iterable} sources + */ + #revalidate(sources) { + /** @type {Set} */ + var seen = new Set(); + + for (const source of sources) { + this.#mark(source, seen); + } + } + + /** + * @param {Value} value + * @param {Set} seen + */ + #mark(value, seen) { + var reactions = value.reactions; + if (reactions === null) return; + + for (const reaction of reactions) { + if (seen.has(reaction)) continue; + seen.add(reaction); + + var flags = reaction.f; + + if ((flags & DERIVED) !== 0) { + if ((flags & CLEAN) !== 0) set_signal_status(reaction, MAYBE_DIRTY); + this.#mark(/** @type {Derived} */ (reaction), seen); + } else if ( + (flags & (EAGER_EFFECT | INERT | DESTROYED)) === 0 && + // branches that this batch skips don't exist in its view of the world + !this.#is_skipped(/** @type {Effect} */ (reaction)) + ) { + this.schedule(/** @type {Effect} */ (reaction), MAYBE_DIRTY); + } + } + } + /** * Traverse the effect tree, executing effects or stashing * them for later execution as appropriate @@ -486,7 +625,12 @@ export class Batch { var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; - var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect); + var skip = + is_skippable_branch || + (flags & INERT) !== 0 || + this.#skipped_branches.has(effect) || + // branches that exist offscreen only for other batches + ((flags & EFFECT_OFFSCREEN) !== 0 && !this.#unskipped_branches.has(effect)); if (!skip && effect.fn !== null) { if (is_branch) { @@ -527,8 +671,8 @@ export class Batch { while (batch !== null) { if (!batch.is_fork) { // if the batches are connected, break - for (const [value, [, is_derived]] of this.current) { - if (batch.current.has(value) && !is_derived) { + for (const value of this.current.keys()) { + if (batch.current.has(value)) { return batch; } } @@ -541,12 +685,18 @@ export class Batch { } /** + * Take over the writes of a later batch that depends on this one, because it finished first. + * Values it produced (async results, deriveds) remain valid; block effects select their + * branches again for this batch, if needed (their selections are keyed by batch) * @param {Batch} batch */ #merge(batch) { for (const [source, value] of batch.current) { if (!this.previous.has(source) && batch.previous.has(source)) { - this.previous.set(source, batch.previous.get(source)); + this.previous.set( + source, + /** @type {{ v: any, wv: number }} */ (batch.previous.get(source)) + ); } this.current.set(source, value); @@ -557,55 +707,22 @@ export class Batch { if (d) deferred.promise.then(d.resolve).catch(d.reject); } + for (const [reaction, record] of batch.records) { + if ((reaction.f & BLOCK_EFFECT) === 0) { + this.records.set(reaction, record); + } + } + // Clear them or else those that are still pending might get rejected on discard (after merged-into batch is done). // This can happen when batch Y merged into X and Y has a pending boundary and therefore still-pending async deriveds inside. batch.async_deriveds.clear(); - // Mark is not guaranteed not touch these, so we transfer them this.transfer_reactions(batch.#dirty_reactions); - /** - * mark all effects that depend on `batch.current`, except the - * async effects that we just resolved (TODO unless they depend - * on values in this batch that are NOT in the later batch?). - * Through this we also will populate the correct #skipped_branches, - * oncommit callbacks etc, so we don't need to merge them separately. - * @param {Value} value - */ - const mark = (value) => { - var reactions = value.reactions; - if (reactions === null) return; - // skip if value is derived and is neither dirty nor maybe dirty. transitive - // deriveds (a derived depending on another derived) are only MAYBE_DIRTY, so - // we must continue traversing them to reach the effects that depend on them - if ((value.f & DERIVED) !== 0 && (value.f & (DIRTY | MAYBE_DIRTY)) === 0) { - return; - } - - for (const reaction of reactions) { - var flags = reaction.f; - - if ((flags & DERIVED) !== 0) { - mark(/** @type {Derived} */ (reaction)); - } else { - var effect = /** @type {Effect} */ (reaction); - - if (flags & (ASYNC | BLOCK_EFFECT) && !this.async_deriveds.has(effect)) { - this.#dirty_reactions.delete(effect); - set_signal_status(effect, DIRTY); - this.schedule(effect); - } - } - } - }; - - for (const source of this.current.keys()) { - mark(source); - } - this.oncommit(() => batch.discard()); batch.#unlink(); + // processing re-validates everything that depends on the (now merged) writes of this batch current_batch = this; this.#process(); } @@ -621,25 +738,78 @@ export class Batch { /** * Associate a change to a given source with the current - * batch, noting its previous and current values + * batch, noting its previous and current values (and versions). + * The source itself is only updated if this is its latest value, i.e. unless this + * is a fork (whose writes only exist in its own view of the world) or a later batch + * already wrote to it (e.g. because an async expression resolved in an earlier batch). * @param {Value} source * @param {any} value - * @param {boolean} [is_derived] + * @returns {boolean} whether the value is the latest one */ - capture(source, value, is_derived = false) { - if (source.v !== UNINITIALIZED && !this.previous.has(source)) { - this.previous.set(source, source.v); + capture(source, value) { + var latest = !this.is_fork; + + /** @type {Batch | null} */ + var later = null; + + for (var batch = this.next; batch !== null; batch = batch.next) { + if (!batch.is_fork && batch.current.has(source)) { + later = batch; + latest = false; + break; + } } - // Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get` - if ((source.f & ERROR_VALUE) === 0) { - this.current.set(source, [value, is_derived]); - batch_values?.set(source, value); + if (!this.previous.has(source)) { + // the value before this batch's change is the one before the later batch's change, if any + var previous = later !== null ? later.previous.get(source) : source; + + if (previous !== undefined && previous.v !== UNINITIALIZED) { + this.previous.set(source, { v: previous.v, wv: previous.wv }); + } } - if (!this.is_fork) { + var is_error = (source.f & ERROR_VALUE) !== 0; + + // errors are never batch-local, or they wouldn't be thrown in `runtime.js#get` + if (is_error && !this.is_fork) latest = true; + + // the same value has the same version (it can equal the real value if this + // batch sees another one, see `internal_set`) — in which case there's nothing to update + var same = !is_error && source.equals(value); + if (same) latest = false; + + var entry = { + v: value, + wv: same ? source.wv : latest ? increment_write_version() : next_local_version() + }; + + if (!is_error) { + this.current.set(source, entry); + batch_values?.set(source, entry); + } + + if (latest) { source.v = value; + source.wv = entry.wv; } + + return latest; + } + + /** + * Make the value that `source` has in this batch's view of the world this batch's own + * value, without changing anything. It then takes precedence over the values of other + * batches that this batch's view includes (see `folded`), should those change + * @param {Value} source + */ + keep(source) { + if (this.current.has(source) || (source.f & ERROR_VALUE) !== 0) return; + + var entry = batch_values?.get(source) ?? { v: source.v, wv: source.wv }; + + this.previous.set(source, entry); + this.current.set(source, entry); } activate() { @@ -649,6 +819,7 @@ export class Batch { deactivate() { current_batch = null; batch_values = null; + view_batch = null; } flush() { @@ -661,6 +832,17 @@ export class Batch { current_batch = this; this.#process(); + + for (const batch of rebases) { + rebases.delete(batch); + + // the batch is processed again anyway if a flush is queued + if (batch.linked && !batch.#decrement_queued) { + old_values.clear(); + current_batch = batch; + batch.#process(); + } + } } finally { flush_count = 0; last_scheduled_effect = null; @@ -670,6 +852,7 @@ export class Batch { current_batch = null; batch_values = null; + view_batch = null; old_values.clear(); @@ -693,31 +876,24 @@ export class Batch { this.#deferred?.resolve(); } - /** - * @param {Effect} effect - */ - register_created_effect(effect) { - this.#new_effects.push(effect); - } - #commit() { // If there are other pending batches, they now need to be 'rebased' — - // in other words, we re-run block/async effects with the newly - // committed state, unless the batch in question has a more - // recent value for a given source + // in other words, their view of the world now includes the newly committed + // state (unless the batch in question has a more recent value for a given source), + // so everything that depends on it needs to be re-validated in their view for (let batch = first_batch; batch !== null; batch = batch.next) { var is_earlier = batch.id < this.id; /** @type {Source[]} */ var sources = []; - for (const [source, [value, is_derived]] of this.current) { - if (batch.current.has(source)) { - var batch_value = /** @type {[any, boolean]} */ (batch.current.get(source))[0]; // faster than destructuring + for (const [source, value] of this.current) { + var own = batch.current.get(source); - if (is_earlier && value !== batch_value) { + if (own !== undefined) { + if (is_earlier && value.v !== own.v) { // bring the value up to date - batch.current.set(source, [value, is_derived]); + batch.current.set(source, value); } else { // same value or later batch has more recent value, // no need to re-run these effects @@ -737,17 +913,19 @@ export class Batch { } } - var current = [...batch.current.keys()].filter( - (source) => !(/** @type {[any, boolean]} */ (batch.current.get(source))[1]) - ); - // If not started yet or no sources to update (which is e.g. possible for the very first batch) then bail - if (!batch.started || current.length === 0) continue; + if (!batch.started || batch.current.size === 0) continue; + + var obsolete = true; - // Re-run async/block effects that depend on distinct values changed in both batches (ignoring deriveds) - var others = current.filter((source) => !this.current.has(source)); + for (const source of batch.current.keys()) { + if (!this.current.has(source)) { + obsolete = false; + break; + } + } - if (others.length === 0) { + if (obsolete) { if (is_earlier) { // this batch is now obsolete and can be discarded batch.discard(); @@ -762,65 +940,23 @@ export class Batch { // A batch was unskipped in a later batch -> tell prior batches to unskip it, too if (is_earlier) { for (const unskipped of this.#unskipped_branches) { - batch.unskip_effect(unskipped, (e) => { + batch.unskip_effect(unskipped, (e, status) => { if ((e.f & (BLOCK_EFFECT | ASYNC)) !== 0) { - batch.schedule(e); + batch.schedule(e, status); } else { - batch.#defer_effects([e]); + batch.add_dirty_reaction(e, status); } }); } } - batch.activate(); - - /** @type {Set} */ - var marked = new Set(); - - /** @type {Map} */ - var checked = new Map(); - - for (var source of sources) { - mark_effects(source, others, marked, checked); - } - - checked = new Map(); - var current_unequal = [...batch.current] - .filter(([c, v1]) => { - const v2 = this.current.get(c); - if (!v2) return true; - // Either their values are different or one is a derived but not the other - return v2[0] !== v1[0] || v2[1] !== v1[1]; - }) - .map(([c]) => c); - - if (current_unequal.length > 0) { - for (const effect of this.#new_effects) { - if ( - (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 && - depends_on(effect, current_unequal, checked) - ) { - if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) { - set_signal_status(effect, DIRTY); - batch.schedule(effect); - } else { - batch.add_dirty_reaction(effect, DIRTY); - } - } - } - } - - // Only apply and traverse when we know we triggered async work with marking the effects - // and know this won't run anyway right afterwards - if (batch.#scheduled.length > 0 && !batch.#decrement_queued) { - batch.apply(); - - for (var root of batch.#resolve()) { - batch.#traverse(root, [], []); - } - } - - batch.deactivate(); + // Everything that depends on the committed values needs to be re-validated in the + // batch's view. The batch is processed again once the current flush is done (which + // also re-validates what depends on its own writes, as the committed batch may have + // created reactions that read them), so that block effects select their branches + // and async work restarts in the new view right away + batch.#revalidate(sources); + rebases.add(batch); } } } @@ -918,18 +1054,27 @@ export class Batch { return current_batch; } + /** + * Make this batch's view of the world the active one + */ apply() { - if (!async_mode_flag || (!this.is_fork && this.prev === null && this.next === null)) { + if (!async_mode_flag) { + batch_values = null; + return; + } + + view_batch = this; + this.folded = []; + + if (!this.is_fork && this.prev === null && this.next === null) { + // this batch's view is the real world batch_values = null; return; } // if there are multiple batches, we are 'time travelling' — // we need to override values with the ones in this batch... - batch_values = new Map(); - for (const [source, [value]] of this.current) { - batch_values.set(source, value); - } + batch_values = new Map(this.current); // ...and undo changes belonging to other batches unless they intersect for (let batch = first_batch; batch !== null; batch = batch.next) { @@ -940,11 +1085,7 @@ export class Batch { var intersects = false; if (batch.id < this.id) { - for (const [source, [, is_derived]] of batch.current) { - // Derived values don't partake in the intersection mechanism, because a derived could - // be triggered in one batch already but not the other one yet, causing a false-positive - if (is_derived) continue; - + for (const source of batch.current.keys()) { if (this.current.has(source)) { intersects = true; break; @@ -957,9 +1098,12 @@ export class Batch { // effects of the earlier batch in case they are merged. // As a result you can think of batch_values as having the latest values of all intersecting // batches up until this batch. - if (!intersects) { + if (intersects) { + this.folded.push(batch); + } else { for (const [source, previous] of batch.previous) { - if (!batch_values.has(source)) { + // (where the views agree on the value, the real one — and its version — applies) + if (!batch_values.has(source) && !source.equals(previous.v)) { batch_values.set(source, previous); } } @@ -968,12 +1112,68 @@ export class Batch { } /** - * + * The record of `reaction` in this batch's view of the world, i.e. that of this batch + * or of the (earlier) batches whose changes it includes, if any + * @param {Reaction} reaction + * @returns {BatchRecord | undefined} + */ + record(reaction) { + var record = this.records.get(reaction); + + for (var i = this.folded.length - 1; record === undefined && i >= 0; i--) { + record = this.folded[i].records.get(reaction); + } + + return record; + } + + /** + * Schedule `effect` to be (re-)validated when this batch is processed. The status is only + * applied right away if this is the batch that the current flush is processing (or processes + * next, if the write happens while effects run), or if the effect is the one that is + * currently running (i.e. it invalidated itself). Otherwise it remains with + * this batch until it is processed: whether an effect is dirty depends on a batch's view of + * the world, so other batches (which may be processed first) mustn't treat it as dirty. + * The effect is then checked against the exact versions of its dependencies (i.e. it + * becomes MAYBE_DIRTY). * @param {Effect} effect + * @param {number} status DIRTY or MAYBE_DIRTY */ - schedule(effect) { + schedule(effect, status) { last_scheduled_effect = effect; + if ((is_processing && current_batch === this) || effect === active_reaction) { + // don't set a DIRTY effect to MAYBE_DIRTY + if ((effect.f & DIRTY) === 0) { + set_signal_status(effect, status); + } + + this.#add(effect); + } else { + this.#unprocessed.add(effect); + } + } + + /** + * Apply the statuses of the effects that were scheduled while this batch wasn't being processed + */ + #apply_unprocessed() { + for (const effect of this.#unprocessed) { + // don't set a DIRTY effect to MAYBE_DIRTY + if ((effect.f & DIRTY) === 0) { + set_signal_status(effect, MAYBE_DIRTY); + } + + this.#add(effect); + } + + this.#unprocessed.clear(); + } + + /** + * @param {Effect} effect + */ + #add(effect) { // defer render effects inside a pending boundary // TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later if ( @@ -1009,6 +1209,7 @@ export class Batch { } this.linked = false; + this.records.clear(); } } @@ -1163,37 +1364,6 @@ function flush_queued_effects(effects) { eager_block_effects = null; } -/** - * This is similar to `mark_reactions`, but it only marks async/block effects - * depending on `value` and at least one of the other `sources`, so that - * these effects can re-run after another batch has been committed - * @param {Value} value - * @param {Source[]} sources - * @param {Set} marked - * @param {Map} checked - */ -function mark_effects(value, sources, marked, checked) { - if (marked.has(value)) return; - marked.add(value); - - if (value.reactions !== null) { - for (const reaction of value.reactions) { - const flags = reaction.f; - - if ((flags & DERIVED) !== 0) { - mark_effects(/** @type {Derived} */ (reaction), sources, marked, checked); - } else if ( - (flags & (ASYNC | BLOCK_EFFECT)) !== 0 && - (flags & DIRTY) === 0 && - depends_on(reaction, sources, checked) - ) { - set_signal_status(reaction, DIRTY); - schedule_effect(/** @type {Effect} */ (reaction)); - } - } - } -} - /** * When committing a fork, we need to trigger eager effects so that * any `$state.eager(...)` expressions update immediately. This @@ -1216,39 +1386,13 @@ function mark_eager_effects(value, effects) { } } -/** - * @param {Reaction} reaction - * @param {Source[]} sources - * @param {Map} checked - */ -function depends_on(reaction, sources, checked) { - const depends = checked.get(reaction); - if (depends !== undefined) return depends; - - if (reaction.deps !== null) { - for (const dep of reaction.deps) { - if (includes.call(sources, dep)) { - return true; - } - - if ((dep.f & DERIVED) !== 0 && depends_on(/** @type {Derived} */ (dep), sources, checked)) { - checked.set(/** @type {Derived} */ (dep), true); - return true; - } - } - } - - checked.set(reaction, false); - - return false; -} - /** * @param {Effect} effect + * @param {number} status DIRTY or MAYBE_DIRTY * @returns {void} */ -export function schedule_effect(effect) { - /** @type {Batch} */ (current_batch).schedule(effect); +export function schedule_effect(effect, status) { + /** @type {Batch} */ (current_batch).schedule(effect, status); } /** @type {Source[]} */ @@ -1303,6 +1447,7 @@ export function eager(fn) { var previous_batch_values = batch_values; try { + // evaluate in the real world batch_values = null; value = fn(); } finally { @@ -1402,6 +1547,7 @@ export function fork(fn) { var batch = Batch.ensure(); batch.is_fork = true; batch_values = new Map(); + view_batch = batch; var committed = false; var settled = batch.settled(); @@ -1424,10 +1570,18 @@ export function fork(fn) { batch.is_fork = false; - // apply changes and update write versions so deriveds see the change - for (var [source, [value]] of batch.current) { - source.v = value; - source.wv = increment_write_version(); + // Apply the changes to the real world. They keep the versions they had in the fork, + // so that everything the fork computed remains valid (processing the batch + // re-validates everything that depends on them, see `#process`) + for (var [source, entry] of batch.current) { + source.v = entry.v; + source.wv = entry.wv; + + if ((source.f & DERIVED) !== 0 && /** @type {BatchRecord} */ (entry).dv !== undefined) { + // an assigned derived, which holds its value until its dependencies change + promote_record(/** @type {Derived} */ (source), /** @type {BatchRecord} */ (entry)); + update_derived_status(/** @type {Derived} */ (source)); + } } // trigger any `$state.eager(...)` expressions with the new state. @@ -1451,13 +1605,6 @@ export function fork(fn) { await settled; }, discard: () => { - // cause any MAYBE_DIRTY deriveds to update - // if they depend on things that changed - // inside the discarded fork - for (var source of batch.current.keys()) { - source.wv = increment_write_version(); - } - if (!committed && batch.linked) { batch.discard(); } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 728ef1d214..a50a3066f6 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -12,41 +12,41 @@ import { DESTROYED, CLEAN, REACTION_RAN, - INERT + INERT, + CONNECTED } from '#client/constants'; import { active_reaction, active_effect, update_reaction, increment_write_version, + next_local_version, set_active_effect, push_reaction_value, is_destroying_effect, update_effect, remove_reactions, skipped_deps, - new_deps + new_deps, + batch_record, + set_batch_record, + is_current, + promote_record } from '../runtime.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; import * as w from '../warnings.js'; -import { - async_effect, - destroy_effect, - destroy_effect_children, - effect_tracking, - teardown -} from './effects.js'; +import { async_effect, destroy_effect, destroy_effect_children, teardown } from './effects.js'; import { eager_effects, internal_set, set_eager_effects, source } from './sources.js'; import { get_error } from '../../shared/dev.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { component_context } from '../context.js'; import { UNINITIALIZED } from '../../../constants.js'; -import { batch_values, current_batch, previous_batch } from './batch.js'; +import { batch_values, current_batch, view_batch } from './batch.js'; import { increment_pending, unset_context } from './async.js'; import { deferred, includes, noop } from '../../shared/utils.js'; -import { set_signal_status, update_derived_status } from './status.js'; +import { update_derived_status } from './status.js'; /** * This allows us to track 'reactivity loss' that occurs when signals @@ -81,6 +81,7 @@ export function derived(fn) { const signal = { ctx: component_context, deps: null, + dv: null, effects: null, equals, f: flags, @@ -218,13 +219,15 @@ export function async_derived(fn, label, location) { if (error === OBSOLETE) return; + // write the result in the batch's view of the world batch.activate(); + batch.apply(); if (error) { signal.f |= ERROR_VALUE; // @ts-expect-error the error is the wrong type, but we don't care - internal_set(signal, error); + internal_set(signal, error, null, true); } else { if ((signal.f & ERROR_VALUE) !== 0) { signal.f ^= ERROR_VALUE; @@ -241,7 +244,7 @@ export function async_derived(fn, label, location) { }); } - internal_set(signal, value); + internal_set(signal, value, null, true); } batch.deactivate(); @@ -341,6 +344,8 @@ export function execute_derived(derived) { var prev_active_effect = active_effect; var parent = derived.parent; + set_batch_record(null); + if ( !is_destroying_effect && parent !== null && @@ -384,58 +389,132 @@ export function execute_derived(derived) { } /** + * Re-evaluate `derived` in the real world (i.e. with the latest values of everything) + * and update its global state * @param {Derived} derived * @returns {void} */ export function update_derived(derived) { + var record = view_batch?.records.get(derived); + + if (record !== undefined && is_current(record.deps, record.dv)) { + // The active batch evaluated the derived in its view of the world, which turns out to + // be the real world now (e.g. because the batch is a fork that was committed) — adopt it + view_batch?.records.delete(derived); + promote_record(derived, record); + derived.v = record.v; + derived.wv = /** @type {number} */ (record.wv); + update_derived_status(derived); + return; + } + var value = execute_derived(derived); + // don't update the derived if we're reading it inside a + // cleanup function, or it will cache a stale value + if (is_destroying_effect) { + return; + } + if (!derived.equals(value)) { + derived.v = value; derived.wv = increment_write_version(); + } - // in a fork, we don't update the underlying value, just `batch_values`. - // the underlying value will be updated when the fork is committed. - // otherwise, the next time we get here after a 'real world' state - // change, `derived.equals` may incorrectly return `true` - if (!current_batch?.is_fork || derived.deps === null) { - if (current_batch !== null) { - // We also write to previous_batch because if it exists, it is a sign that we're - // currently in the process of flushing effects. These updates to deriveds may belong - // to the previous batch, not the new one (which can already exist if an earlier - // effect wrote to a source). This can cause bugs when running batch.#commit() later, - // but not adding it to current_batch can, too, so we add it to both. - // See https://github.com/sveltejs/svelte/pull/18117 for more details. - current_batch.capture(derived, value, true); - previous_batch?.capture(derived, value, true); - } else { + update_derived_status(derived); +} + +/** + * The value of `derived` as the view of the world of the active batch sees it, with its + * version, and whether it is the latest (real-world) value. The derived's global state + * describes the real world, so it is only used if it is valid within the view (i.e. the + * values it was computed from are what the view holds), and only updated if the derived is + * re-evaluated from values that are all the latest ones. Otherwise, the result is recorded + * in the batch, so that it can be reused for as long as it remains valid in its view. + * Results are cached for the lifetime of the view (see `Batch#apply`), unless the derived is + * disconnected, in which case nothing would remove them from the cache when they become stale. + * @param {Derived} derived + * @returns {{ v: any, wv: number, l?: boolean }} + */ +export function view_entry(derived) { + var values = /** @type {Map} */ (batch_values); + var entry = values.get(derived); + + if (entry !== undefined) { + // either cached, or the derived was overridden via assignment in this batch + return entry; + } + + var batch = /** @type {Batch} */ (view_batch); + var record = batch.record(derived); + + if (record !== undefined && is_current(record.deps, record.dv)) { + entry = { v: record.v, wv: /** @type {number} */ (record.wv), l: false }; + } else if ( + (derived.f & REACTION_RAN) !== 0 && + derived.dv !== null && + is_current(derived.deps, derived.dv) + ) { + var latest = is_latest(derived); + entry = { v: derived.v, wv: derived.wv, l: latest }; + + // valid in the real world, too + if (latest && (derived.f & CLEAN) === 0) update_derived_status(derived); + } else { + var value = execute_derived(derived); + + if (batch_record === null) { + // everything it read is the latest value — this is the real-world value + if (!derived.equals(value)) { derived.v = value; + derived.wv = increment_write_version(); } - // deriveds without dependencies should never be recomputed - if (derived.deps === null) { - set_signal_status(derived, CLEAN); - return; - } + update_derived_status(derived); + entry = { v: derived.v, wv: derived.wv, l: true }; + } else { + // Reuse the version of an equal value, so that reactions which saw it remain valid + var wv = + record !== undefined && derived.equals.call(/** @type {any} */ (record), value) + ? /** @type {number} */ (record.wv) + : (derived.f & REACTION_RAN) !== 0 && derived.dv !== null && derived.equals(value) + ? derived.wv + : next_local_version(); + + batch_record.v = value; + batch_record.wv = wv; + entry = { v: value, wv, l: false }; } } - // don't mark derived clean if we're reading it inside a - // cleanup function, or it will cache a stale value - if (is_destroying_effect) { - return; + if ((derived.f & CONNECTED) !== 0) { + values.set(derived, entry); } - // During time traveling we don't want to reset the status so that - // traversal of the graph in the other batches still happens - if (batch_values !== null) { - // only cache the value if we're in a tracking context, otherwise we won't - // clear the cache in `mark_reactions` when dependencies are updated - if (effect_tracking() || current_batch?.is_fork) { - batch_values.set(derived, value); + return entry; +} + +/** + * Whether the values that the global state of `derived` was computed from — which are + * known to be what the active view holds — are also the latest values + * @param {Derived} derived + */ +function is_latest(derived) { + var deps = /** @type {Value[]} */ (derived.deps); + var dv = /** @type {number[]} */ (derived.dv); + var values = /** @type {Map} */ (batch_values); + + for (var i = 0; i < dv.length; i++) { + var dep = deps[i]; + + if ((dep.f & DERIVED) !== 0) { + if (values.get(dep)?.l !== true) return false; + } else if (dep.wv !== dv[i]) { + return false; } - } else { - update_derived_status(derived); } + + return true; } /** diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index e69a639a94..134429a26c 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -43,7 +43,7 @@ import { DEV } from 'esm-env'; import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js'; -import { Batch, collected_effects, current_batch } from './batch.js'; +import { Batch, collected_effects } from './batch.js'; import { flatten } from './async.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { set_signal_status } from './status.js'; @@ -103,6 +103,7 @@ function create_effect(type, fn) { var effect = { ctx: component_context, deps: null, + dv: null, nodes: null, f: type | DIRTY | CONNECTED, first: null, @@ -121,8 +122,6 @@ function create_effect(type, fn) { effect.component_function = dev_current_component_function; } - current_batch?.register_created_effect(effect); - /** @type {Effect | null} */ var e = effect; @@ -132,7 +131,7 @@ function create_effect(type, fn) { collected_effects.push(effect); } else { // schedule for later - Batch.ensure().schedule(effect); + Batch.ensure().schedule(effect, DIRTY); } } else if (fn !== null) { try { @@ -706,8 +705,7 @@ function resume_children(effect, local) { // here because we don't want to eagerly recompute a derived like // `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined if ((effect.f & CLEAN) === 0) { - set_signal_status(effect, DIRTY); - Batch.ensure().schedule(effect); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe + Batch.ensure().schedule(effect, DIRTY); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe } var child = effect.first; diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 14d7b9a2e5..9c60dfbadf 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -1,4 +1,4 @@ -/** @import { Derived, Effect, Source, Value } from '#client' */ +/** @import { BatchRecord, Derived, Effect, Source, Value } from '#client' */ import { DEV } from 'esm-env'; import { active_reaction, @@ -7,13 +7,14 @@ import { get, set_untracked_writes, untrack, - increment_write_version, update_effect, current_sources, is_dirty, untracking, is_destroying_effect, - push_reaction_value + push_reaction_value, + track_own_write, + version_of } from '../runtime.js'; import { equals, safe_equals } from './equality.js'; import { @@ -25,7 +26,8 @@ import { MAYBE_DIRTY, BLOCK_EFFECT, ROOT_EFFECT, - ASYNC + ASYNC, + REACTION_RAN } from '#client/constants'; import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; @@ -184,10 +186,17 @@ var count_deps = 0; * @param {Value} source * @param {V} value * @param {Effect[] | null} [updated_during_traversal] + * @param {boolean} [own_result] Whether this is a result the current batch computed (i.e. an async + * expression resolved). Such a value belongs to the batch even if it equals what the batch sees, and + * it is a change if it differs from that (even if it equals the real value) * @returns {V} */ -export function internal_set(source, value, updated_during_traversal = null) { - if (!source.equals(value)) { +export function internal_set(source, value, updated_during_traversal = null, own_result = false) { + var current = own_result ? batch_values?.get(source) ?? source : source; + + if (own_result && source.equals.call(/** @type {Value} */ (current), value)) { + Batch.ensure().keep(source); + } else if (!source.equals.call(/** @type {Value} */ (current), value)) { if (is_destroying_effect) { old_values.set(source, value); } else if (!old_values.has(source)) { @@ -197,7 +206,15 @@ export function internal_set(source, value, updated_during_traversal = null) { } var batch = Batch.ensure(); - batch.capture(source, value); + + if ((source.f & DERIVED) !== 0 && (source.f & (DIRTY | REACTION_RAN)) !== REACTION_RAN) { + // if we are assigning to a derived that is dirty (or was never evaluated) + // we eagerly execute it to track its dependencies + execute_derived(/** @type {Derived} */ (source)); + } + + var latest = batch.capture(source, value); + track_own_write(source); if (DEV) { if (tracing_mode_flag || active_effect !== null) { @@ -232,20 +249,21 @@ export function internal_set(source, value, updated_during_traversal = null) { if ((source.f & DERIVED) !== 0) { const derived = /** @type {Derived} */ (source); - // if we are assigning to a dirty derived we set it to clean/maybe dirty but we also eagerly execute it to track the dependencies - if ((source.f & DIRTY) !== 0) { - execute_derived(derived); - } + // The assigned value holds until one of the derived's dependencies changes, + // i.e. it is valid for the versions they currently have + var dv = derived.deps === null ? [] : derived.deps.map(version_of); - // During time traveling we don't want to reset the status so that - // traversal of the graph in the other batches still happens - if (batch_values === null) { + if (latest) { + derived.dv = dv; update_derived_status(derived); + } else { + // the assignment only exists in this batch (e.g. a fork) until it is committed + var entry = /** @type {BatchRecord} */ (batch.current.get(derived)); + entry.deps = derived.deps === null ? null : derived.deps.slice(); + entry.dv = dv; } } - source.wv = increment_write_version(); - // For debugging, in case you want to know which reactions are being scheduled: // log_reactions(source); seen = null; @@ -379,32 +397,38 @@ function mark_reactions(signal, status, updated_during_traversal) { var not_dirty = (flags & DIRTY) === 0; - // don't set a DIRTY reaction to MAYBE_DIRTY - if (not_dirty) { - set_signal_status(reaction, status); - } + if ((flags & (EAGER_EFFECT | DERIVED)) !== 0) { + // don't set a DIRTY reaction to MAYBE_DIRTY + if (not_dirty) { + set_signal_status(reaction, status); + } - if ((flags & EAGER_EFFECT) !== 0) { - // Eager effects need to run immediately: - // - for $inspect so that the stack trace makes sense - // - for $state.eager because they might be without an effect parent - eager_effects.add(/** @type {Effect} */ (reaction)); - } else if ((flags & DERIVED) !== 0) { - var derived = /** @type {Derived} */ (reaction); + if ((flags & EAGER_EFFECT) !== 0) { + // Eager effects need to run immediately: + // - for $inspect so that the stack trace makes sense + // - for $state.eager because they might be without an effect parent + eager_effects.add(/** @type {Effect} */ (reaction)); + } else { + var derived = /** @type {Derived} */ (reaction); - batch_values?.delete(derived); - mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal); + batch_values?.delete(derived); + mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal); + } } else if (not_dirty) { var effect = /** @type {Effect} */ (reaction); if ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) { + // these run right away (see `flush_queued_effects`) + set_signal_status(effect, status); eager_block_effects.add(effect); } if (updated_during_traversal !== null) { + set_signal_status(effect, status); updated_during_traversal.push(effect); } else { - schedule_effect(effect); + // the status is applied when the batch is processed (see `Batch#schedule`) + schedule_effect(effect, status); } } } diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 0ee8570c3d..c038af262a 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -46,12 +46,34 @@ export interface Reaction extends Signal { ctx: null | ComponentContext; /** The reaction function */ fn: null | Function; - /** Signals that this signal reads from */ + /** + * Signals that this signal reads from. These are the edges of the graph, and therefore + * a superset of the dependencies of every live record of this reaction (see `dv`) + */ deps: null | Value[]; + /** + * The exact versions of the dependencies read by the last run whose result is + * global (i.e. real-world) state, aligned with the first `dv.length` entries of + * `deps`. The reaction is up to date wherever its dependencies have exactly these + * versions. `null` if no such run exists (or it was invalidated) + */ + dv: null | number[]; /** An AbortController that aborts when the signal is destroyed */ ac: null | AbortController; } +/** + * The result of a run of a reaction that belongs to a batch rather than to the real world: + * the values it read (in order) and their exact versions at the time — and, for + * deriveds, the resulting value and its (batch-local) version + */ +export interface BatchRecord { + deps: null | Value[]; + dv: number[]; + v?: any; + wv?: number; +} + export interface Derived extends Value, Reaction { /** The derived function */ fn: () => V; diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index f09aaf34ee..fae6a0b9ee 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -1,4 +1,4 @@ -/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ +/** @import { BatchRecord, Derived, Effect, Reaction, Source, Value } from '#client' */ import { DEV } from 'esm-env'; import { get_descriptors, get_prototype_of, includes, index_of } from '../shared/utils.js'; import { @@ -22,7 +22,8 @@ import { STALE_REACTION, ERROR_VALUE, MANAGED_EFFECT, - REACTION_RAN + REACTION_RAN, + ASYNC } from './constants.js'; import { old_values } from './reactivity/sources.js'; import { @@ -31,7 +32,8 @@ import { freeze_derived_effects, recent_async_deriveds, unfreeze_derived_effects, - update_derived + update_derived, + view_entry } from './reactivity/deriveds.js'; import { async_mode_flag, tracing_mode_flag } from '../flags/index.js'; import { tracing_expressions } from './dev/tracing.js'; @@ -49,9 +51,11 @@ import { Batch, batch_values, current_batch, + first_batch, flushSync, previous_batch, - schedule_effect + schedule_effect, + view_batch } from './reactivity/batch.js'; import { handle_error } from './error-handling.js'; import { UNINITIALIZED } from '../../constants.js'; @@ -126,11 +130,21 @@ export function set_untracked_writes(value) { } /** - * @type {number} Used by sources and deriveds for handling updates. + * @type {number} Used by sources and deriveds for handling updates. Only incremented for + * real-world (i.e. latest) values — values that only exist within a batch get a + * batch-local version instead (see `next_local_version`). * Version starts from 1 so that unowned deriveds differentiate between a created effect and a run one for tracing **/ export let write_version = 1; +/** + * Versions of values that only exist within a batch (writes that are not the latest + * value of a source, e.g. in a fork, and deriveds computed in a batch's view of the world). + * Versions are only ever compared for identity, so these are negative to never collide + * with real-world versions. + */ +let local_version = -1; + /** @type {number} Used to version each read of a source of derived to avoid duplicating dependencies inside a reaction */ let read_version = 0; @@ -145,9 +159,100 @@ export function increment_write_version() { return ++write_version; } +export function next_local_version() { + return --local_version; +} + +/** + * The versions of the values the currently updating reaction read, in the order they + * were first read (i.e. aligned with `deps.slice(0, skipped_deps).concat(new_deps)`) + * @type {number[]} + */ +let read_versions = []; + +/** + * Whether every value the currently updating reaction read so far was its latest (real-world) + * value, rather than a value that only exists in the view of the active batch + */ +let read_latest = true; + +/** + * Sources the currently updating reaction wrote to + * @type {Set | null} + */ +let own_writes = null; + +/** + * Called when `source` is written to, so that the reaction that is currently updating + * (if any) isn't invalidated by its own write + * @param {Value} source + */ +export function track_own_write(source) { + if (active_reaction !== null && (active_reaction.f & REACTION_IS_UPDATING) !== 0) { + (own_writes ??= new Set()).add(source); + } +} + +/** + * Set by `update_reaction` if the run produced a batch-local record rather than updating the + * reaction's global state, so that the caller can complete it (see `view_entry`) + * @type {BatchRecord | null} + */ +export let batch_record = null; + +/** @param {BatchRecord | null} value */ +export function set_batch_record(value) { + batch_record = value; +} + +/** + * The version `signal` has in the active view of the world + * @param {Value} signal + */ +export function version_of(signal) { + if (batch_values === null) return signal.wv; + + if ((signal.f & DERIVED) !== 0) { + return view_entry(/** @type {Derived} */ (signal)).wv; + } + + var entry = batch_values.get(signal); + return entry === undefined ? signal.wv : entry.wv; +} + +/** + * Whether the values with the given versions are (still) what `deps` hold in the active view of the world. + * Dependencies are checked in order and deriveds are brought up to date along the way, so that we + * don't evaluate a derived that the reaction would no longer read (e.g. `a ? b : c` where `a` changed) + * @param {Value[] | null} deps + * @param {number[]} dv + */ +export function is_current(deps, dv) { + for (var i = 0; i < dv.length; i++) { + var dependency = /** @type {Value[]} */ (deps)[i]; + + if (batch_values === null && (dependency.f & DERIVED) !== 0) { + var derived = /** @type {Derived} */ (dependency); + + if (is_dirty(derived)) { + update_derived(derived); + } + } + + if (version_of(dependency) !== dv[i]) { + return false; + } + } + + return true; +} + /** * Determines whether a derived or effect is dirty. - * If it is MAYBE_DIRTY, will set the status to CLEAN + * If it is MAYBE_DIRTY, will set the status to CLEAN. + * Deriveds are only checked like this in the real world — within the view of a batch, + * see `view_entry`. Effects are checked against their record in the active batch, if + * they have one (see `record_of`), or else their global state. * @param {Reaction} reaction * @returns {boolean} */ @@ -159,27 +264,14 @@ export function is_dirty(reaction) { } if ((flags & MAYBE_DIRTY) !== 0) { - var dependencies = /** @type {Value[]} */ (reaction.deps); - var length = dependencies.length; - - for (var i = 0; i < length; i++) { - var dependency = dependencies[i]; - - if (is_dirty(/** @type {Derived} */ (dependency))) { - update_derived(/** @type {Derived} */ (dependency)); - } + var record = (flags & DERIVED) === 0 ? record_of(/** @type {Effect} */ (reaction)) : undefined; + var dv = record === undefined ? reaction.dv : record.dv; - if (dependency.wv > reaction.wv) { - return true; - } + if (dv === null || !is_current(record === undefined ? reaction.deps : record.deps, dv)) { + return true; } - if ( - (flags & CONNECTED) !== 0 && - // During time traveling we don't want to reset the status so that - // traversal of the graph in the other batches still happens - batch_values === null - ) { + if ((flags & CONNECTED) !== 0) { set_signal_status(reaction, CLEAN); } } @@ -187,6 +279,29 @@ export function is_dirty(reaction) { return false; } +/** + * Block and async effects produce a result per batch (the branch a batch selected, the + * value an async expression resolved to for a batch), so each batch keeps a record of its own + * run of them until it commits. Other effects only have a single (global) state. + * @param {Effect} effect + */ +function record_of(effect) { + if (view_batch === null || (effect.f & (BLOCK_EFFECT | ASYNC)) === 0) return undefined; + return view_batch.record(effect); +} + +/** + * Whether some batch holds a record of `reaction` + * @param {Reaction} reaction + */ +function has_record(reaction) { + for (var batch = first_batch; batch !== null; batch = batch.next) { + if (batch.records.size > 0 && batch.records.has(reaction)) return true; + } + + return false; +} + /** * @param {Value} signal * @param {Effect} effect @@ -206,12 +321,7 @@ function schedule_possible_effect_self_invalidation(signal, effect, root = true) if ((reaction.f & DERIVED) !== 0) { schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false); } else if (effect === reaction) { - if (root) { - set_signal_status(reaction, DIRTY); - } else if ((reaction.f & CLEAN) !== 0) { - set_signal_status(reaction, MAYBE_DIRTY); - } - schedule_effect(/** @type {Effect} */ (reaction)); + schedule_effect(/** @type {Effect} */ (reaction), root ? DIRTY : MAYBE_DIRTY); } } } @@ -226,6 +336,9 @@ export function update_reaction(reaction) { var previous_component_context = component_context; var previous_untracking = untracking; var previous_update_version = update_version; + var previous_read_versions = read_versions; + var previous_read_latest = read_latest; + var previous_own_writes = own_writes; var flags = reaction.f; @@ -233,6 +346,9 @@ export function update_reaction(reaction) { skipped_deps = 0; untracked_writes = null; active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null; + read_versions = []; + read_latest = true; + own_writes = null; current_sources = null; set_component_context(reaction.ctx); @@ -252,7 +368,7 @@ export function update_reaction(reaction) { var fn = /** @type {Function} */ (reaction.fn); var result = fn(); reaction.f |= REACTION_RAN; - var deps = update_dependencies(reaction); + var read_any = update_dependencies(reaction, (flags & REACTION_RAN) === 0); // If we're inside an effect and we have untracked writes, then we need to // ensure that if any of those untracked writes result in re-invalidation @@ -261,7 +377,7 @@ export function update_reaction(reaction) { is_runes() && untracked_writes !== null && !untracking && - deps !== null && + read_any && (reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0 ) { for (var i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) { @@ -309,7 +425,7 @@ export function update_reaction(reaction) { return result; } catch (error) { // still commit the deps read before the throw, otherwise deriveds connected by this run keep no reader and the reaction never re-runs when they change - update_dependencies(reaction); + update_dependencies(reaction, (flags & REACTION_RAN) === 0); return handle_error(error); } finally { @@ -322,28 +438,127 @@ export function update_reaction(reaction) { set_component_context(previous_component_context); untracking = previous_untracking; update_version = previous_update_version; + read_versions = previous_read_versions; + read_latest = previous_read_latest; + own_writes = previous_own_writes; } } /** + * Determine where the result of the current run of `reaction` belongs, record the + * versions of the values it read accordingly, and update the edges of the graph. + * + * A run belongs to the real world — and updates the reaction's global state — unless + * - it is a derived that read values which only exist in the view of the active batch + * (it is then cached in that batch, see `view_entry`) + * - it is a block or async effect that ran while the active batch was being processed + * (its result belongs to that batch until it commits, see `record_of`). The first run of + * a block effect is the exception: it creates and commits its initial branch right away + * - it is a derived evaluated in a teardown, in which case its result is discarded + * + * The edges of the graph (`reaction.deps` and the `reactions` of its dependencies) are a + * superset of the dependencies of every live record, so that changes to any of them reach + * the reaction. They are only pruned when a global run happens and no batch holds a record. * @param {Reaction} reaction + * @param {boolean} first_run + * @returns {boolean} whether the run read anything */ -function update_dependencies(reaction) { +function update_dependencies(reaction, first_run) { var deps = reaction.deps; + var length = skipped_deps + (new_deps === null ? 0 : new_deps.length); - // Don't remove reactions during fork; - // they must remain for when fork is discarded - var is_fork = current_batch?.is_fork; + batch_record = null; - if (new_deps !== null) { - var i; + if (is_destroying_effect && (reaction.f & DERIVED) !== 0) { + return length > 0; + } + + var dv = read_versions.length === length ? read_versions : read_versions.slice(0, length); + + // anything read after this (e.g. while an error is handled) is not part of the run + read_versions = []; - if (!is_fork) { + // A reaction isn't invalidated by its own writes (unless it is explicitly marked dirty + // because of them, see `schedule_possible_effect_self_invalidation`), so sources it + // wrote to count as seen with the version they have at the end of the run + if (own_writes !== null) { + for (var i = 0; i < length; i++) { + var dep = + i < skipped_deps + ? /** @type {Value[]} */ (deps)[i] + : /** @type {Value[]} */ (new_deps)[i - skipped_deps]; + + if (own_writes.has(dep)) { + var entry = batch_values?.get(dep); + dv[i] = entry === undefined ? dep.wv : entry.wv; + } + } + } + + /** @type {Batch | null} */ + var batch = null; + + if ((reaction.f & DERIVED) !== 0) { + if (batch_values !== null && !read_latest) { + batch = view_batch; + } + } else if ( + ((reaction.f & ASYNC) !== 0 || ((reaction.f & BLOCK_EFFECT) !== 0 && !first_run)) && + view_batch !== null && + !view_batch.committing + ) { + batch = view_batch; + } + + var tracking = effect_tracking() && (reaction.f & CONNECTED) !== 0; + + if (batch !== null) { + /** @type {Value[] | null} */ + var read = null; + + if (length > 0) { + read = deps === null ? [] : deps.slice(0, skipped_deps); + + if (new_deps !== null) { + for (i = 0; i < new_deps.length; i++) { + dep = new_deps[i]; + read.push(dep); + + if (deps === null || !includes.call(deps, dep)) { + (reaction.deps ??= []).push(dep); + if (tracking) (dep.reactions ??= []).push(reaction); + } + } + } + } + + batch.records.set(reaction, (batch_record = { deps: read, dv })); + + return length > 0; + } + + // a record of the reaction in the active batch would now be outdated + if (view_batch !== null && view_batch.records.size > 0) { + view_batch.records.delete(reaction); + } + + var keep = deps !== null && skipped_deps < deps.length && has_record(reaction); + + if (new_deps !== null) { + if (keep) { + // Records in other batches may depend on values this run didn't read — keep those edges, + // after the ones that were read, so that `dv` lines up with the start of `deps` + for (i = skipped_deps; i < /** @type {Value[]} */ (deps).length; i++) { + dep = /** @type {Value[]} */ (deps)[i]; + if (!includes.call(new_deps, dep)) new_deps.push(dep); + } + } else { remove_reactions(reaction, skipped_deps); } if (deps !== null && skipped_deps > 0) { deps.length = skipped_deps + new_deps.length; + for (i = 0; i < new_deps.length; i++) { deps[skipped_deps + i] = new_deps[i]; } @@ -351,17 +566,45 @@ function update_dependencies(reaction) { reaction.deps = deps = new_deps; } - if (effect_tracking() && (reaction.f & CONNECTED) !== 0) { + if (tracking) { for (i = skipped_deps; i < deps.length; i++) { - (deps[i].reactions ??= []).push(reaction); + dep = deps[i]; + var reactions = (dep.reactions ??= []); + if (!keep || !includes.call(reactions, reaction)) reactions.push(reaction); } } - } else if (!is_fork && deps !== null && skipped_deps < deps.length) { + } else if (deps !== null && skipped_deps < deps.length && !keep) { remove_reactions(reaction, skipped_deps); deps.length = skipped_deps; } - return deps; + reaction.dv = dv; + + return length > 0; +} + +/** + * Make the record a batch holds of `reaction` its global state, e.g. because the batch + * committed, which makes its results the real-world ones + * @param {Reaction} reaction + * @param {BatchRecord} record + */ +export function promote_record(reaction, record) { + var deps = reaction.deps; + var read = record.deps; + + if (read !== null && deps !== null && deps !== read) { + // `dv` must line up with the start of `deps` + var reordered = read.slice(); + + for (var dep of deps) { + if (!includes.call(read, dep)) reordered.push(dep); + } + + reaction.deps = reordered; + } + + reaction.dv = record.dv; } /** @@ -478,6 +721,9 @@ export function update_effect(effect) { execute_effect_teardown(effect); var teardown = update_reaction(effect); effect.teardown = typeof teardown === 'function' ? teardown : null; + + // Whether an effect is dirty is determined by the exact versions of its dependencies (see + // `update_dependencies`) — this only marks when it last ran, which `$inspect.trace` uses effect.wv = write_version; // In DEV, increment versions of any sources that were written to during the effect, @@ -543,6 +789,15 @@ export function get(signal) { captured_signals?.add(signal); + /** + * The position of `signal` among the values read by the updating reaction, + * if this is the first time it reads it, so that we can record its version + */ + var index = -1; + + /** The reaction that reads `signal` after an `await`, if any */ + var late_reader = null; + // Register the dependency on the current reaction signal. if (active_reaction !== null && !untracking) { // if we're in a derived that is being read inside an _async_ derived, @@ -562,14 +817,18 @@ export function get(signal) { // order as it did last time, increment `skipped_deps` // rather than updating `new_deps`, which creates GC cost if (new_deps === null && deps !== null && deps[skipped_deps] === signal) { - skipped_deps++; + index = skipped_deps++; } else if (new_deps === null) { + index = skipped_deps; new_deps = [signal]; } else { + index = skipped_deps + new_deps.length; new_deps.push(signal); } } } else { + late_reader = active_reaction; + // We're adding a dependency outside the init/update cycle (i.e. after an `await`). // We have to deduplicate deps/reactions in this case or remove_reactions could // disconnect deps/reactions that are actually still in use (if skip_deps says @@ -648,14 +907,14 @@ export function get(signal) { } } - if (is_destroying_effect && old_values.has(signal)) { - return old_values.get(signal); - } - - if (is_derived) { - var derived = /** @type {Derived} */ (signal); + if (is_destroying_effect) { + // teardowns see the values from before the update; nothing read here is recorded + if (old_values.has(signal)) { + return old_values.get(signal); + } - if (is_destroying_effect) { + if (is_derived) { + var derived = /** @type {Derived} */ (signal); var value = derived.v; // if the derived is dirty and has reactions, or depends on the values that just changed, re-execute @@ -671,6 +930,22 @@ export function get(signal) { return value; } + } + + /** The version of the value we return */ + var version = signal.wv; + + /** Whether the value we return is the latest (real-world) one */ + var latest = true; + + /** + * The value (and version) as the active view of the world sees it, if it differs from the real world + * @type {{ v: any, wv: number, l?: boolean } | undefined} + */ + var view; + + if (is_derived) { + derived = /** @type {Derived} */ (signal); // connect disconnected deriveds when reading them inside a connected reaction var should_connect = @@ -681,13 +956,15 @@ export function get(signal) { var is_new = (derived.f & REACTION_RAN) === 0; - if (is_dirty(derived)) { - if (should_connect) { - // set the flag before `update_derived`, so that the derived - // is added as a reaction to its dependencies - derived.f |= CONNECTED; - } + if (should_connect) { + // set the flag before the derived is (re)evaluated, so that + // it is added as a reaction to its dependencies + derived.f |= CONNECTED; + } + if (batch_values !== null) { + view = view_entry(derived); + } else if (is_dirty(derived)) { update_derived(derived); } @@ -695,10 +972,31 @@ export function get(signal) { unfreeze_derived_effects(derived); reconnect(derived); } + + if (view !== undefined) { + version = view.wv; + latest = view.l === true; + } else { + version = derived.wv; + } + } else if (batch_values !== null) { + view = batch_values.get(signal); + + if (view !== undefined) { + version = view.wv; + latest = version === signal.wv; + } } - if (batch_values?.has(signal)) { - return batch_values.get(signal); + if (index !== -1) { + read_versions[index] = version; + if (!latest) read_latest = false; + } else if (late_reader !== null) { + record_late_read(late_reader, signal, version); + } + + if (view !== undefined) { + return view.v; } if ((signal.f & ERROR_VALUE) !== 0) { @@ -708,6 +1006,43 @@ export function get(signal) { return signal.v; } +/** + * Record that `reaction` read `signal` (with the given version) after an `await`, i.e. after + * its run — as part of the record of that run + * @param {Reaction} reaction + * @param {Value} signal + * @param {number} version + */ +function record_late_read(reaction, signal, version) { + var record = + (reaction.f & DERIVED) === 0 ? record_of(/** @type {Effect} */ (reaction)) : undefined; + + if (record !== undefined) { + if (record.deps === null) { + record.deps = [signal]; + record.dv.push(version); + } else if (!includes.call(record.deps, signal)) { + record.deps.push(signal); + record.dv.push(version); + } + + return; + } + + var dv = reaction.dv; + if (dv === null) return; + + var deps = /** @type {Value[]} */ (reaction.deps); + var i = deps.indexOf(signal); + + if (i >= dv.length) { + // move it next to the other values read by the run, so that `dv` lines up with the start of `deps` + deps[i] = deps[dv.length]; + deps[dv.length] = signal; + dv.push(version); + } +} + /** * (Re)connect a disconnected derived, so that it is notified * of changes in `mark_reactions` diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte new file mode 100644 index 0000000000..eee3e94e31 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/Sync.svelte @@ -0,0 +1,5 @@ + + +{c} diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js new file mode 100644 index 0000000000..6c6f8100e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// An effect that several batches scheduled must run in each of them, even if another batch is flushed first +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [a, b, resolve_and_write, resolve] = target.querySelectorAll('button'); + const buttons = + ''; + assert.htmlEqual(target.innerHTML, `${buttons}

0 0

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

0 0

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

0 1

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

1 1

11`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/main.svelte new file mode 100644 index 0000000000..18ff344f79 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects-multiple/main.svelte @@ -0,0 +1,33 @@ + + + + + + + +

{await load(a)} {await load(b)}

+{await load(a)} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Async.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Async.svelte new file mode 100644 index 0000000000..f24b8d26a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Async.svelte @@ -0,0 +1,6 @@ + + +

block: {result}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte new file mode 100644 index 0000000000..057e1db394 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/Sync.svelte @@ -0,0 +1,5 @@ + + +{y} diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js new file mode 100644 index 0000000000..5f860cf2e6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// An effect must run in the batch that scheduled it, not in another batch that happens to be flushed first +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [button] = target.querySelectorAll('button'); + assert.htmlEqual(target.innerHTML, '

block: 0

0'); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, '

block: 1

1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/main.svelte new file mode 100644 index 0000000000..b40ff30317 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-flush-stolen-effects/main.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js new file mode 100644 index 0000000000..11c3bce995 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ''; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, b, shift] = target.querySelectorAll('button'); + + // batch A waits on its key; batch B registers its key right away + a.click(); + await tick(); + b.click(); + await tick(); + + // the older batch commits first + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

0

1

${buttons}`); + + // the newer batch commits with its own branch + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

1

B

${buttons}`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte new file mode 100644 index 0000000000..d0b1a57053 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-commit-order/main.svelte @@ -0,0 +1,24 @@ + + +

{await delay(b)}

+ +{#key k} +

{k}

+{/key} + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js new file mode 100644 index 0000000000..22c939cfaf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js @@ -0,0 +1,35 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [x, y, shift] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + '

0

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

1

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte new file mode 100644 index 0000000000..3a9323d8ce --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/main.svelte @@ -0,0 +1,23 @@ + + +

{await delay(x)}

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

1

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/main.svelte new file mode 100644 index 0000000000..1d1d10752f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-derived/main.svelte @@ -0,0 +1,24 @@ + + +

{await delay(x)}

+ + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte new file mode 100644 index 0000000000..e8da9df1f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/Child.svelte @@ -0,0 +1,20 @@ + + +

{assessment}/{parity}

+{#if parity}

odd

{/if} +{#if schedule >= 0 && parity}

also odd

{/if} +{#if matched}

matched

{/if} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js new file mode 100644 index 0000000000..ec47264eb9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/_config.js @@ -0,0 +1,76 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + await tick(); + await tick(); + const [update, resolve_a_2, resolve_schedule_2, resolve_a_1, read] = + target.querySelectorAll('button'); + assert.htmlEqual( + target.innerHTML, + `${buttons}

0/0

matched

` + ); + assert.deepEqual(logs, [ + ['assessment', 0], + ['schedule', 0], + ['parity', 0], + ['matched', 0, 0] + ]); + logs.length = 0; + + update.click(); + await tick(); + update.click(); + await tick(); + + // The dependency changes, but parity stays 0 and its write version stays + // unchanged. Both if blocks must share one evaluation in this batch's view. + resolve_a_2.click(); + await tick(); + assert.deepEqual(logs, [ + ['assessment', 1], + ['assessment', 2], + ['schedule', 2], + ['parity', 2], + ['matched', 2, 0] + ]); + logs.length = 0; + + // Resume the same batch and read parity again from the second if block. + // Its dependencies have not changed since its previous evaluation. + // In contrast, matched depends on schedule and must be invalidated. + resolve_schedule_2.click(); + await tick(); + // Committing also merges into the earlier batch and flushes the text effect, + // which must reuse the clean parity rather than evaluating it again. + assert.deepEqual(logs, [['matched', 2, 2]]); + assert.htmlEqual( + target.innerHTML, + `${buttons}

2/0

matched

` + ); + logs.length = 0; + + // The superseded result must not cause any further evaluations. + resolve_a_1.click(); + await tick(); + assert.deepEqual(logs, []); + assert.htmlEqual( + target.innerHTML, + `${buttons}

2/0

matched

` + ); + + // Once the competing batches are gone, parity must be globally clean. + // An event-handler read has no batch-local status to fall back on. + read.click(); + assert.deepEqual(logs, [['read', 0]]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte new file mode 100644 index 0000000000..1ef51268f2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-cache/main.svelte @@ -0,0 +1,25 @@ + + + + + + + + + + {#snippet pending()}loading{/snippet} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js new file mode 100644 index 0000000000..a971621615 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/_config.js @@ -0,0 +1,44 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [a, b, log, resolve] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + assert.deepEqual(logs, ['effect 0']); + logs.length = 0; + + // batch A is pending + a.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 0'); + + // batch B commits right away, in a view of the world without A's change + b.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '0 -1'); + assert.deepEqual(logs, ['effect -1']); + logs.length = 0; + + // outside of any batch, `d` has its real value (and is only computed once + // for that, as it wasn't computed with the real values before) + log.click(); + await tick(); + assert.deepEqual(logs, ['real 0 3']); + logs.length = 0; + + // batch A commits: `d` is 0 again, like before B — but the effect last saw -1 + // (a value that only existed in B's view), so it must re-run. `d` is not re-computed + resolve.click(); + await tick(); + assert.htmlEqual(p.innerHTML, '1 0'); + assert.deepEqual(logs, ['effect 0']); + logs.length = 0; + + log.click(); + await tick(); + assert.deepEqual(logs, ['real 0 3']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte new file mode 100644 index 0000000000..f1fe57e638 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-batch-local-value/main.svelte @@ -0,0 +1,25 @@ + + + + + + + +

{await push(a)} {d}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/_config.js new file mode 100644 index 0000000000..65178b5f32 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [a, b, resolve_a, resolve_b] = target.querySelectorAll('button'); + assert.htmlEqual(target.innerHTML, `${buttons}

0:0

0

`); + + // sum is DIRTY and doubled is MAYBE_DIRTY, but neither runs while a is pending. + a.click(); + await tick(); + + // The if block evaluates both deriveds with the latest inputs and marks them clean. + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

0:0

0

`); + + // The earlier batch must restore doubled's MAYBE_DIRTY status as well as sum's + // DIRTY status, otherwise it reuses the cached value from before a changed. + resolve_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

1:2

0

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

1:4

1

ready`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte new file mode 100644 index 0000000000..15d2d6ffc6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-deferred-maybe-dirty/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{await delay('a', a)}:{doubled}

+

{await delay('b', b)}

+{#if b && doubled}ready{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/_config.js index 1a7ed0eb92..12a4e0d521 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-log-outside-reactivity/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO fix async test({ assert, target, logs }) { await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/_config.js index b959ba5407..2a26431ce8 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-overfiring/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO fix async test({ assert, target, logs }) { await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/_config.js index 7d31d30b46..2f6fe7279c 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-not-underfiring/_config.js @@ -2,7 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO fix async test({ assert, target, logs }) { await tick(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js new file mode 100644 index 0000000000..8f021032e0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Batch B (x) commits while an earlier batch A (y) is still pending, but B stays around because +// of the async work in the new boundary. While B flushes its effects, an effect creates a new batch C, +// and `d` is computed for the first time afterwards (so it's captured in both B and C). When A +// commits later, it sees B's previous values, which must be consistent (`d` must be `x * 2`). +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [y, x, shift] = target.querySelectorAll('button'); + assert.deepEqual(logs, [0, 0, 0]); + + // A + y.click(); + await tick(); + + // B + x.click(); + await tick(); + assert.deepEqual(logs, [0, 0, 0, 0, 1, 2]); + + // A commits + shift.click(); + await tick(); + + for (let i = 0; i < logs.length; i += 3) { + assert.equal(logs[i + 2], logs[i + 1] * 2, `torn read: ${logs.slice(i, i + 3)}`); + } + + assert.deepEqual(logs.slice(-3), [1, 1, 2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte new file mode 100644 index 0000000000..5cd0876884 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-previous-effect-batch/main.svelte @@ -0,0 +1,38 @@ + + + + + + +

{y ? await push(y) : y}

+ +{#if x} + +

{await push(x)}

+ {#snippet pending()} +

loading

+ {/snippet} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte new file mode 100644 index 0000000000..6f48eaf129 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/Child.svelte @@ -0,0 +1,9 @@ + + +

child

diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js new file mode 100644 index 0000000000..4fd60a98ce --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target, logs, raf }) { + await tick(); + const [x, s, toggle, shift] = target.querySelectorAll('button'); + assert.deepEqual(logs, ['effect false']); + + // keep an earlier batch pending, so that the next one commits while time travelling + x.click(); + await tick(); + + // `big` stays `false`, so the effect must not re-run + s.click(); + await tick(); + assert.deepEqual(logs, ['effect false']); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

1

child

' + ); + + // outro, then reintro before the outro finished: the branch is resumed + toggle.click(); + await tick(); + raf.tick(50); + toggle.click(); + await tick(); + raf.tick(200); + + assert.deepEqual(logs, ['effect false']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte new file mode 100644 index 0000000000..30d68f614b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-resume-no-rerun/main.svelte @@ -0,0 +1,29 @@ + + + + + + + +

{await push(x)}

+ +{#if show} +
+ +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js new file mode 100644 index 0000000000..e487dea160 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/_config.js @@ -0,0 +1,39 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +// A batch's async result equals the real value, which a later (still pending) batch wrote. The batch +// itself sees that later batch's previous value though, so the result changes its world and has to show +export default test({ + mode: ['client'], + async test({ assert, target }) { + const [b1, b2c1, resolve_latest, resolve_oldest, resolve_all] = + target.querySelectorAll('button'); + + resolve_all.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

zero

0c0`); + + b1.click(); + await tick(); + b2c1.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

zero

0c0`); + + // the later batch's result becomes the real value, but that batch still waits for c1 + resolve_latest.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

zero

0c0`); + + // the earlier batch's equal result completes it + resolve_oldest.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

pos

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

pos

2c1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/main.svelte new file mode 100644 index 0000000000..024cc72c98 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped-later-batch/main.svelte @@ -0,0 +1,34 @@ + + + + + + + + + +

{await f(b > 0 ? 'pos' : 'zero')}

+ {b} + {await f('c' + c)} + {#snippet pending()}{/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js new file mode 100644 index 0000000000..44218567ae --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/_config.js @@ -0,0 +1,47 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +// A batch's async result that equals the current real value (but not the value the batch sees, +// because an earlier pending batch has a different one in flight) must not be dropped, or the +// earlier batch's result wins although the later batch reverted the input +export default test({ + mode: ['client'], + async test({ assert, target }) { + const [b2, b0, resolve_k3, resolve_k0, resolve_all] = target.querySelectorAll('button'); + + resolve_all.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}k0k1`); + + // batch 1 requests k2/k3 + b2.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}k0k1`); + + // batch 2 requests k0/k1, which equal the values currently shown + b0.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}k0k1`); + + resolve_k3.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}k0k1`); + + // batch 2's first result equals the real value + resolve_k0.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}k0k1`); + + resolve_all.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}k0k1`); + + // nothing left over + resolve_all.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}k0k1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte new file mode 100644 index 0000000000..d3cfaadd9d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-dropped/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + {await delay('k' + (0 + b))}{await delay('k' + (1 + b))} + {#snippet pending()}{/snippet} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js b/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js new file mode 100644 index 0000000000..3cc13951df --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/_config.js @@ -0,0 +1,40 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +// Like `async-equal-result-dropped-later-batch`, but the result is read by something that doesn't wait +// for it: a batch's async result equals the real value, which a later (still pending) batch wrote. The +// batch itself sees that later batch's previous value though, so the result changes its world, and +// whatever read the previous value has to update +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [b1, b2c1, resolve_latest, resolve_oldest, resolve_all] = + target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `${buttons}

zero

0c0`); + + b1.click(); + await tick(); + b2c1.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

zero

0c0`); + + // the later batch's result becomes the real value, but that batch still waits for c1 + resolve_latest.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

zero

0c0`); + + // the earlier batch's equal result completes it + resolve_oldest.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

pos

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

pos

2c1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/main.svelte new file mode 100644 index 0000000000..df9941e28f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-equal-result-later-batch-reader/main.svelte @@ -0,0 +1,39 @@ + + + + + + + + +

{p}

+{#if true}{b}{/if} +{await f('c' + c)} 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..5481b9c921 --- /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, logs }) { + const [fork_button, update, resolve, discard] = target.querySelectorAll('button'); + + fork_button.click(); + await tick(); + assert.equal(logs.length, 1); + assert.htmlEqual(target.innerHTML, buttons); + + // Transfer an invalidation into the fork while its async work is pending. + update.click(); + await tick(); + assert.equal(logs.length, 1); // can also be 2 at this point, would also be ok + + try { + resolve.click(); + await tick(); + assert.equal(logs.length, 1); + + // Completing the replacement must not replay the same invalidation. + resolve.click(); + await tick(); + assert.equal(logs.length, 1); + } finally { + discard.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, buttons); + } +}); 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..60fa60cb4a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte @@ -0,0 +1,38 @@ + + + + + + + +{#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-reveal/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/_config.js new file mode 100644 index 0000000000..0b62c027f5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/_config.js @@ -0,0 +1,54 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + +`; + +// A branch that was created by fork `a` must not show `a`'s values when it is +// revealed by another batch (be it a real one or another fork), and `a` must +// still show its own values when it's committed afterwards +export default test({ + async test({ assert, target }) { + const [fork_a, fork_b, reveal, commit_a, commit_b, reset] = target.querySelectorAll('button'); + + for (const mode of ['real', 'fork']) { + try { + fork_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + + if (mode === 'real') { + reveal.click(); + } else { + fork_b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + commit_b.click(); + } + + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}static

0

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

1

12` + ); + + reset.click(); + await tick(); + 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-reveal/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/main.svelte new file mode 100644 index 0000000000..e34a12776c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-reveal/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + + +{#if show} + static +

{count}

+ {#each items as item (item)}{item}{/each} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js new file mode 100644 index 0000000000..f65155ab18 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/_config.js @@ -0,0 +1,53 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// The fallback of an each block, created by a fork, is adopted by a real batch: it has to run the +// fallback's async expression itself, since the fork's result only exists in the fork +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + const [fork_empty, empty, reset, resolve_all, discard, commit] = + target.querySelectorAll('button'); + const [output] = target.querySelectorAll('main'); + + const settle = async () => { + for (let i = 0; i < 5; i++) { + await tick(); + resolve_all.click(); + } + await tick(); + }; + + for (const finish of ['discard', 'commit']) { + for (const resolved of [true, false]) { + const mode = `${finish}, ${resolved ? 'resolved' : 'pending'}`; + + try { + fork_empty.click(); + if (resolved) { + resolve_all.click(); + await tick(); + } + assert.deepEqual(logs.splice(0), ['empty']); + + empty.click(); + await tick(); + assert.deepEqual(logs.splice(0), ['empty']); + + if (finish === 'discard') discard.click(); + else commit.click(); + await settle(); + assert.htmlEqual(output.innerHTML, '

empty

'); + assert.deepEqual(logs, []); + + reset.click(); + await settle(); + assert.htmlEqual(output.innerHTML, '0'); + } catch (e) { + /** @type {Error} */ (e).message = `${mode}: ${/** @type {Error} */ (e).message}`; + throw e; + } + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte new file mode 100644 index 0000000000..a2bdce7bad --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-each-fallback-adopted/main.svelte @@ -0,0 +1,25 @@ + + + + + + + + + +
{#each list as x (x)}{x}{:else}

{await push('empty')}

{/each}
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-equal-result-dropped-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/_config.js new file mode 100644 index 0000000000..7d9a66b8a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/_config.js @@ -0,0 +1,50 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = + ''; + +// Like `async-fork-equal-result-dropped`, but a block depends on the async expression: the fork's +// re-run result equals the real value, but differs from the fork's outdated one, so the block has to +// update in the fork (its effects are considered up to date with the fork once it is committed) +export default test({ + mode: ['client'], + async test({ assert, target }) { + const [fork, a2, shift, commit] = target.querySelectorAll('button'); + + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

other

0|0`); + + // the fork's result is 1 + fork.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

other

0|0`); + + // the real world overtakes the fork's `a`, the real batch and the fork both run the expression with 2 + a2.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

other

0|0`); + + // the real batch's result + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

other

2|0`); + + // the fork's result, which equals the real value + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

other

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

other

2|1`); + + // nothing left over + shift.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

other

2|1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/main.svelte new file mode 100644 index 0000000000..d43ace7050 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-equal-result-dropped-block/main.svelte @@ -0,0 +1,28 @@ + + + + + + + + + {#if (await delay(a)) === 1}

one

{:else}

other

{/if} + {a}|{b} + {#snippet pending()}{/snippet} +
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-hidden-write-version-no-rerun/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/Child.svelte new file mode 100644 index 0000000000..ea6a8ec7b2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/Child.svelte @@ -0,0 +1,14 @@ + + +

{sx}

+

{await slow(s)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js new file mode 100644 index 0000000000..0bedc308a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// When a later batch's write is hidden from an earlier batch, its write version must be +// hidden as well — otherwise reactions of that source look dirty to the earlier batch +// and re-run even though they see the old (unchanged) value +export default test({ + async test({ assert, target, logs }) { + const [x, s, resolve] = target.querySelectorAll('button'); + + resolve.click(); + await tick(); + resolve.click(); + await tick(); + assert.deepEqual(logs, ['0/true']); + + x.click(); // B1: slow(x = 2) pending; `d` stays true + await tick(); + s.click(); // B2: slow(s = 1) pending; the $effect is dirtied by `s` and deferred in B2 + await tick(); + assert.deepEqual(logs, ['0/true']); + + // B1 commits while B2 is pending. In B1's world nothing the effect depends on changed + // (`s` is hidden and still 0, `d` is still true), so it must not run + resolve.click(); + await tick(); + assert.deepEqual(logs, ['0/true']); + + resolve.click(); + await tick(); + assert.deepEqual(logs, ['0/true', '1/true']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte new file mode 100644 index 0000000000..32e1cfb6b3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-hidden-write-version-no-rerun/main.svelte @@ -0,0 +1,23 @@ + + + + + + + + + {#snippet pending()}loading{/snippet} + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js new file mode 100644 index 0000000000..c21996a615 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/_config.js @@ -0,0 +1,31 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + +`; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [start_a, start_b, resolve] = target.querySelectorAll('button'); + + start_a.click(); + await tick(); + start_b.click(); + await tick(); + + // B selected the empty branch before merging into A. A's async result + // now selects the content, which B's old commit callback must not remove. + // The two blocks register A's and B's callbacks in opposite orders. + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}

1/1

readyalso ready` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte new file mode 100644 index 0000000000..605d5d85ea --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-branch-reselection/main.svelte @@ -0,0 +1,24 @@ + + + + + + +

{x}/{result}

+{#if show && x > 0 && result > 0} + ready +{/if} +{#if x > 0 && show && result > 0} + also ready +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/_config.js b/packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/_config.js new file mode 100644 index 0000000000..59c394c468 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/_config.js @@ -0,0 +1,49 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + + + +`; + +// Test ensure dependencies on earlier batches are also merged correctly +export default test({ + async test({ assert, target }) { + const [A0, A, B, resolve_q, resolve_p, resolve_t, resolve_r, init] = + target.querySelectorAll('button'); + + init.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

q0 r0 p0 t0 s0

`); + + A0.click(); // q=1, r=1 -> slow(q=1), slow(r=1) + await tick(); + A.click(); // q=2, p=1, s=1 -> slow(q=2), slow(p=1); depends on A0 + await tick(); + B.click(); // s=2, t=1 -> slow(t=1); depends on A + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

q0 r0 p0 t0 s0

`); + + // A's runs resolve -> A merges into A0, which is still waiting on slow(r=1) + resolve_q.click(); + resolve_p.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

q0 r0 p0 t0 s0

`); + + // B's run resolves -> B must wait for A0 + resolve_t.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

q0 r0 p0 t0 s0

`); + + // A0's run resolves -> everything commits at once + resolve_r.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

q2 r1 p1 t1 s2

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/main.svelte new file mode 100644 index 0000000000..6407c69583 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-merge-dependent-of-merged/main.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + + + + {#snippet pending()}loading{/snippet} +

q{await slow('q', q)} r{await slow('r', r)} p{await slow('p', p)} t{await slow('t', t)} s{s}

+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-2/_config.js index 8276c5be41..2349936245 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-2/_config.js @@ -2,8 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, // TODO this one is tricky - async test({ assert, target }) { const [increment, a, b] = target.querySelectorAll('button'); @@ -31,27 +29,27 @@ export default test({ a.click(); await tick(); - assert.htmlEqual( target.innerHTML, ` -

a: 0

+

a: 2

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

b: 0

b: 3

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

loaded

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-unrelated-update/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-unrelated-update/main.svelte new file mode 100644 index 0000000000..99498f2048 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-unrelated-update/main.svelte @@ -0,0 +1,19 @@ + + + + + + +{#if show && gate > 0} +

{await load()}

+{/if}