From 1482ae8726a40329fe540fb7f20e3517edd318fd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 7 Jan 2026 15:38:00 +0000 Subject: [PATCH 1/3] chore: remove `async_body` (#17425) * chore: remove unused async_body * unused --- packages/svelte/src/internal/client/index.js | 1 - .../src/internal/client/reactivity/async.js | 46 ------------------- 2 files changed, 47 deletions(-) diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index a2add3ec59..a2666d8712 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -98,7 +98,6 @@ export { with_script } from './dom/template.js'; export { - async_body, for_await_track_reactivity_loss, run, save, diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index e48aff3d7f..631af24807 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -25,7 +25,6 @@ import { set_from_async_derived } from './deriveds.js'; import { aborted } from './effects.js'; -import { hydrate_next, hydrating, set_hydrate_node, skip_nodes } from '../dom/hydration.js'; /** * @param {Array>} blockers @@ -211,51 +210,6 @@ export function unset_context() { } } -/** - * @param {TemplateNode} anchor - * @param {(target: TemplateNode) => Promise} fn - */ -export async function async_body(anchor, fn) { - var boundary = get_boundary(); - var batch = /** @type {Batch} */ (current_batch); - var blocking = !boundary.is_pending(); - - boundary.update_pending_count(1); - batch.increment(blocking); - - var active = /** @type {Effect} */ (active_effect); - - var was_hydrating = hydrating; - var next_hydrate_node = undefined; - - if (was_hydrating) { - hydrate_next(); - next_hydrate_node = skip_nodes(false); - } - - try { - var promise = fn(anchor); - } finally { - if (next_hydrate_node) { - set_hydrate_node(next_hydrate_node); - hydrate_next(); - } - } - - try { - await promise; - } catch (error) { - if (!aborted(active)) { - invoke_error_boundary(error, active); - } - } finally { - boundary.update_pending_count(-1); - batch.decrement(blocking); - - unset_context(); - } -} - /** * @param {Array<() => void | Promise>} thunks */ From 41e86438145d42c8ddf00fe1a44eaf3a7b8319c8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 7 Jan 2026 16:39:10 +0000 Subject: [PATCH 2/3] fix: clear batch between runs (alternative approach) (#17424) * fix: clear batch between runs * changeset * add comment and note-to-self * associate effects with boundaries, albeit clumsily * remove unused async_body * dry out * fix * simplify * clarify * tweak * unused * unused * add an explanatory comment --- .changeset/tall-clocks-turn.md | 5 + .../src/internal/client/dom/blocks/async.js | 2 +- .../internal/client/dom/blocks/boundary.js | 65 ++++++--- .../src/internal/client/reactivity/async.js | 12 +- .../src/internal/client/reactivity/batch.js | 124 ++++++------------ .../internal/client/reactivity/deriveds.js | 2 +- .../src/internal/client/reactivity/utils.js | 40 ++++++ .../Child.svelte | 15 +++ .../async-clear-batch-between-runs/_config.js | 32 +++++ .../main.svelte | 19 +++ 10 files changed, 209 insertions(+), 107 deletions(-) create mode 100644 .changeset/tall-clocks-turn.md create mode 100644 packages/svelte/src/internal/client/reactivity/utils.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/main.svelte diff --git a/.changeset/tall-clocks-turn.md b/.changeset/tall-clocks-turn.md new file mode 100644 index 0000000000..22cefcff30 --- /dev/null +++ b/.changeset/tall-clocks-turn.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: clear batch between runs diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 3d320d2ea9..3bc1eecba7 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -21,7 +21,7 @@ import { get_boundary } from './boundary.js'; export function async(node, blockers = [], expressions = [], fn) { var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); - var blocking = !boundary.is_pending(); + var blocking = boundary.is_rendered(); boundary.update_pending_count(1); batch.increment(blocking); diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 140e876330..92f4c0a104 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -2,8 +2,10 @@ import { BOUNDARY_EFFECT, COMMENT_NODE, + DIRTY, EFFECT_PRESERVED, - EFFECT_TRANSPARENT + EFFECT_TRANSPARENT, + MAYBE_DIRTY } from '#client/constants'; import { HYDRATION_START_ELSE } from '../../../../constants.js'; import { component_context, set_component_context } from '../../context.js'; @@ -20,7 +22,8 @@ import { active_reaction, get, set_active_effect, - set_active_reaction + set_active_reaction, + set_signal_status } from '../../runtime.js'; import { hydrate_next, @@ -34,11 +37,12 @@ import { queue_micro_task } from '../task.js'; import * as e from '../../errors.js'; import * as w from '../../warnings.js'; import { DEV } from 'esm-env'; -import { Batch } from '../../reactivity/batch.js'; +import { Batch, schedule_effect } from '../../reactivity/batch.js'; import { internal_set, source } from '../../reactivity/sources.js'; import { tag } from '../../dev/tracing.js'; import { createSubscriber } from '../../../../reactivity/create-subscriber.js'; import { create_text } from '../operations.js'; +import { defer_effect } from '../../reactivity/utils.js'; /** * @typedef {{ @@ -64,7 +68,7 @@ export class Boundary { /** @type {Boundary | null} */ parent; - #pending = false; + is_pending = false; /** @type {TemplateNode} */ #anchor; @@ -101,6 +105,12 @@ export class Boundary { #is_creating_fallback = false; + /** @type {Set} */ + #dirty_effects = new Set(); + + /** @type {Set} */ + #maybe_dirty_effects = new Set(); + /** * A source containing the number of pending async deriveds/expressions. * Only created if `$effect.pending()` is used inside the boundary, @@ -134,7 +144,7 @@ export class Boundary { this.parent = /** @type {Effect} */ (active_effect).b; - this.#pending = !!this.#props.pending; + this.is_pending = !!this.#props.pending; this.#effect = block(() => { /** @type {Effect} */ (active_effect).b = this; @@ -164,7 +174,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.#pending = false; + this.is_pending = false; } } @@ -187,7 +197,7 @@ export class Boundary { // Since server rendered resolved content, we never show pending state // Even if client-side async operations are still running, the content is already displayed - this.#pending = false; + this.is_pending = false; } #hydrate_pending_content() { @@ -212,7 +222,7 @@ export class Boundary { this.#pending_effect = null; }); - this.#pending = false; + this.is_pending = false; } }); } @@ -220,7 +230,7 @@ export class Boundary { #get_anchor() { var anchor = this.#anchor; - if (this.#pending) { + if (this.is_pending) { this.#pending_anchor = create_text(); this.#anchor.before(this.#pending_anchor); @@ -231,11 +241,19 @@ export class Boundary { } /** - * Returns `true` if the effect exists inside a boundary whose pending snippet is shown + * Defer an effect inside a pending boundary until the boundary resolves + * @param {Effect} effect + */ + defer_effect(effect) { + defer_effect(effect, this.#dirty_effects, this.#maybe_dirty_effects); + } + + /** + * Returns `false` if the effect exists inside a boundary whose pending snippet is shown * @returns {boolean} */ - is_pending() { - return this.#pending || (!!this.parent && this.parent.is_pending()); + is_rendered() { + return !this.is_pending && (!this.parent || this.parent.is_rendered()); } has_pending_snippet() { @@ -298,7 +316,24 @@ export class Boundary { this.#pending_count += d; if (this.#pending_count === 0) { - this.#pending = false; + this.is_pending = false; + + // any effects that were encountered and deferred during traversal + // should be rescheduled — after the next traversal (which will happen + // immediately, due to the same update that brought us here) + // the effects will be flushed + for (const e of this.#dirty_effects) { + set_signal_status(e, DIRTY); + schedule_effect(e); + } + + for (const e of this.#maybe_dirty_effects) { + set_signal_status(e, MAYBE_DIRTY); + schedule_effect(e); + } + + this.#dirty_effects.clear(); + this.#maybe_dirty_effects.clear(); if (this.#pending_effect) { pause_effect(this.#pending_effect, () => { @@ -394,7 +429,7 @@ export class Boundary { // we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset // but it would be really weird to show the parent's boundary on a child reset. - this.#pending = this.has_pending_snippet(); + this.is_pending = this.has_pending_snippet(); this.#main_effect = this.#run(() => { this.#is_creating_fallback = false; @@ -404,7 +439,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.#pending = false; + this.is_pending = false; } }; diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 631af24807..334bf8f145 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -218,7 +218,7 @@ export function run(thunks) { var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); - var blocking = !boundary.is_pending(); + var blocking = boundary.is_rendered(); boundary.update_pending_count(1); batch.increment(blocking); @@ -252,17 +252,13 @@ export function run(thunks) { throw STALE_REACTION; } - try { - restore(); - return fn(); - } finally { - // TODO do we need it here as well as below? - unset_context(); - } + restore(); + return fn(); }) .catch(handle_error) .finally(() => { unset_context(); + current_batch?.deactivate(); }); promises.push(promise); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 6f941c7ff2..6dd90ba1b4 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1,5 +1,6 @@ /** @import { Fork } from 'svelte' */ /** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ +/** @import { Boundary } from '../dom/blocks/boundary' */ import { BLOCK_EFFECT, BRANCH_EFFECT, @@ -17,7 +18,6 @@ import { EAGER_EFFECT, HEAD_EFFECT, ERROR_VALUE, - WAS_MARKED, MANAGED_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; @@ -37,15 +37,7 @@ import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; import { flush_eager_effects, old_values, set_eager_effects, source, update } from './sources.js'; import { eager_effect, unlink_effect } from './effects.js'; - -/** - * @typedef {{ - * parent: EffectTarget | null; - * effect: Effect | null; - * effects: Effect[]; - * render_effects: Effect[]; - * }} EffectTarget - */ +import { defer_effect } from './utils.js'; /** @type {Set} */ const batches = new Set(); @@ -161,16 +153,14 @@ export class Batch { this.apply(); - /** @type {EffectTarget} */ - var target = { - parent: null, - effect: null, - effects: [], - render_effects: [] - }; + /** @type {Effect[]} */ + var effects = []; + + /** @type {Effect[]} */ + var render_effects = []; for (const root of root_effects) { - this.#traverse_effect_tree(root, target); + this.#traverse_effect_tree(root, effects, render_effects); // Note: #traverse_effect_tree runs block effects eagerly, which can schedule effects, // which means queued_root_effects now may be filled again. @@ -183,16 +173,16 @@ export class Batch { } if (this.is_deferred()) { - this.#defer_effects(target.effects); - this.#defer_effects(target.render_effects); + this.#defer_effects(render_effects); + this.#defer_effects(effects); } else { // If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with // newly updated sources, which could lead to infinite loops when effects run over and over again. previous_batch = this; current_batch = null; - flush_queued_effects(target.render_effects); - flush_queued_effects(target.effects); + flush_queued_effects(render_effects); + flush_queued_effects(effects); previous_batch = null; @@ -206,13 +196,17 @@ export class Batch { * Traverse the effect tree, executing effects or stashing * them for later execution as appropriate * @param {Effect} root - * @param {EffectTarget} target + * @param {Effect[]} effects + * @param {Effect[]} render_effects */ - #traverse_effect_tree(root, target) { + #traverse_effect_tree(root, effects, render_effects) { root.f ^= CLEAN; var effect = root.first; + /** @type {Effect | null} */ + var pending_boundary = null; + while (effect !== null) { var flags = effect.f; var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; @@ -220,24 +214,32 @@ export class Batch { var skip = is_skippable_branch || (flags & INERT) !== 0 || this.skipped_effects.has(effect); - if ((effect.f & BOUNDARY_EFFECT) !== 0 && effect.b?.is_pending()) { - target = { - parent: target, - effect, - effects: [], - render_effects: [] - }; + // Inside a `` with a pending snippet, + // all effects are deferred until the boundary resolves + // (except block/async effects, which run immediately) + if ( + async_mode_flag && + pending_boundary === null && + (flags & BOUNDARY_EFFECT) !== 0 && + effect.b?.is_pending + ) { + pending_boundary = effect; } if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; + } else if ( + pending_boundary !== null && + (flags & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 + ) { + /** @type {Boundary} */ (pending_boundary.b).defer_effect(effect); } else if ((flags & EFFECT) !== 0) { - target.effects.push(effect); + effects.push(effect); } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) { - target.render_effects.push(effect); + render_effects.push(effect); } else if (is_dirty(effect)) { - if ((effect.f & BLOCK_EFFECT) !== 0) this.#dirty_effects.add(effect); + if ((flags & BLOCK_EFFECT) !== 0) this.#dirty_effects.add(effect); update_effect(effect); } @@ -253,14 +255,8 @@ export class Batch { effect = effect.next; while (effect === null && parent !== null) { - if (parent === target.effect) { - // TODO rather than traversing into pending boundaries and deferring the effects, - // could we just attach the effects _to_ the pending boundary and schedule them - // once the boundary is ready? - this.#defer_effects(target.effects); - this.#defer_effects(target.render_effects); - - target = /** @type {EffectTarget} */ (target.parent); + if (parent === pending_boundary) { + pending_boundary = null; } effect = parent.next; @@ -273,36 +269,8 @@ export class Batch { * @param {Effect[]} effects */ #defer_effects(effects) { - for (const e of effects) { - if ((e.f & DIRTY) !== 0) { - this.#dirty_effects.add(e); - } else if ((e.f & MAYBE_DIRTY) !== 0) { - this.#maybe_dirty_effects.add(e); - } - - // Since we're not executing these effects now, we need to clear any WAS_MARKED flags - // so that other batches can correctly reach these effects during their own traversal - this.#clear_marked(e.deps); - - // mark as clean so they get scheduled if they depend on pending async state - set_signal_status(e, CLEAN); - } - } - - /** - * @param {Value[] | null} deps - */ - #clear_marked(deps) { - if (deps === null) return; - - for (const dep of deps) { - if ((dep.f & DERIVED) === 0 || (dep.f & WAS_MARKED) === 0) { - continue; - } - - dep.f ^= WAS_MARKED; - - this.#clear_marked(/** @type {Derived} */ (dep).deps); + for (var i = 0; i < effects.length; i += 1) { + defer_effect(effects[i], this.#dirty_effects, this.#maybe_dirty_effects); } } @@ -383,14 +351,6 @@ export class Batch { var previous_batch_values = batch_values; var is_earlier = true; - /** @type {EffectTarget} */ - var dummy_target = { - parent: null, - effect: null, - effects: [], - render_effects: [] - }; - for (const batch of batches) { if (batch === this) { is_earlier = false; @@ -439,10 +399,10 @@ export class Batch { batch.apply(); for (const root of queued_root_effects) { - batch.#traverse_effect_tree(root, dummy_target); + batch.#traverse_effect_tree(root, [], []); } - // TODO do we need to do anything with `target`? defer block effects? + // TODO do we need to do anything with the dummy effect arrays? batch.deactivate(); } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index ee8ba29c21..9dc324d3f9 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -150,7 +150,7 @@ export function async_derived(fn, label, location) { var batch = /** @type {Batch} */ (current_batch); if (should_suspend) { - var blocking = !boundary.is_pending(); + var blocking = boundary.is_rendered(); boundary.update_pending_count(1); batch.increment(blocking); diff --git a/packages/svelte/src/internal/client/reactivity/utils.js b/packages/svelte/src/internal/client/reactivity/utils.js new file mode 100644 index 0000000000..0dd6b3212d --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/utils.js @@ -0,0 +1,40 @@ +/** @import { Derived, Effect, Value } from '#client' */ +import { CLEAN, DERIVED, DIRTY, MAYBE_DIRTY, WAS_MARKED } from '#client/constants'; +import { set_signal_status } from '../runtime.js'; + +/** + * @param {Value[] | null} deps + */ +function clear_marked(deps) { + if (deps === null) return; + + for (const dep of deps) { + if ((dep.f & DERIVED) === 0 || (dep.f & WAS_MARKED) === 0) { + continue; + } + + dep.f ^= WAS_MARKED; + + clear_marked(/** @type {Derived} */ (dep).deps); + } +} + +/** + * @param {Effect} effect + * @param {Set} dirty_effects + * @param {Set} maybe_dirty_effects + */ +export function defer_effect(effect, dirty_effects, maybe_dirty_effects) { + if ((effect.f & DIRTY) !== 0) { + dirty_effects.add(effect); + } else if ((effect.f & MAYBE_DIRTY) !== 0) { + maybe_dirty_effects.add(effect); + } + + // Since we're not executing these effects now, we need to clear any WAS_MARKED flags + // so that other batches can correctly reach these effects during their own traversal + clear_marked(effect.deps); + + // mark as clean so they get scheduled if they depend on pending async state + set_signal_status(effect, CLEAN); +} diff --git a/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/Child.svelte new file mode 100644 index 0000000000..d156cc99af --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/Child.svelte @@ -0,0 +1,15 @@ + + +

x: {x}

+ + + {#snippet pending()} +

Loading...

+ {/snippet} + +

y: {y}

+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/_config.js b/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/_config.js new file mode 100644 index 0000000000..fbf003f8e2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + + +

loading...

+ `, + + async test({ assert, target }) { + await tick(); + + const [button1, button2] = target.querySelectorAll('button'); + + button1.click(); + await tick(); + + button2.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

x: x2

+

y: y2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/main.svelte new file mode 100644 index 0000000000..57ab32a6ca --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-clear-batch-between-runs/main.svelte @@ -0,0 +1,19 @@ + + + + + + + + + {#snippet pending()} +

loading...

+ {/snippet} +
From 044dce9da54cdf2dc1c8d268038364c4ac22b110 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 7 Jan 2026 18:37:01 +0000 Subject: [PATCH 3/3] fix: adjust `loc` property of `Program` nodes created from `