From 7c1408d6df8a3bc26cbda8ccf44c5fd200524b96 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 11:32:23 -0400 Subject: [PATCH 01/15] chore: run boundary async effects in the context of the current batch --- .changeset/selfish-pets-teach.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 26 +++---------------- 2 files changed, 9 insertions(+), 22 deletions(-) create mode 100644 .changeset/selfish-pets-teach.md diff --git a/.changeset/selfish-pets-teach.md b/.changeset/selfish-pets-teach.md new file mode 100644 index 000000000000..d78fea8f9fb8 --- /dev/null +++ b/.changeset/selfish-pets-teach.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: run boundary async effects in the context of the current batch diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2956e7ed6afe..fd2a6d9f5dcf 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -97,13 +97,6 @@ export class Batch { */ #deferred = null; - /** - * Async effects inside a newly-created `` - * — these do not prevent the batch from committing - * @type {Effect[]} - */ - #boundary_async_effects = []; - /** * Template effects and `$effect.pre` effects, which run when * a batch is committed @@ -158,8 +151,7 @@ export class Batch { this.#traverse_effect_tree(root); } - // if we didn't start any new async work, and no async work - // is outstanding from a previous flush, commit + // if there is no outstanding async work, commit if (this.#pending === 0) { // TODO we need this because we commit _then_ flush effects... // maybe there's a way we can reverse the order? @@ -193,12 +185,6 @@ export class Batch { } batch_values = null; - - for (const effect of this.#boundary_async_effects) { - update_effect(effect); - } - - this.#boundary_async_effects = []; } /** @@ -225,13 +211,9 @@ export class Batch { this.#effects.push(effect); } else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) { this.#render_effects.push(effect); - } else if ((flags & CLEAN) === 0) { - if ((flags & ASYNC) !== 0 && effect.b?.is_pending()) { - this.#boundary_async_effects.push(effect); - } else if (is_dirty(effect)) { - if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); - update_effect(effect); - } + } else if (is_dirty(effect)) { + if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); + update_effect(effect); } var child = effect.first; From c3db9ac13779d642b9eb4471b5fb1d381c8b2516 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 16:12:43 -0400 Subject: [PATCH 02/15] WIP --- .../internal/client/dom/blocks/boundary.js | 17 +-- .../src/internal/client/reactivity/async.js | 10 +- .../src/internal/client/reactivity/batch.js | 114 ++++++++++++------ .../internal/client/reactivity/deriveds.js | 15 +-- 4 files changed, 87 insertions(+), 69 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index ca6437e3841f..039eded21bd9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -87,7 +87,7 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; - #local_pending_count = 0; + local_pending_count = 0; #pending_count = 0; #is_creating_fallback = false; @@ -103,12 +103,12 @@ export class Boundary { #effect_pending_update = () => { if (this.#effect_pending) { - internal_set(this.#effect_pending, this.#local_pending_count); + internal_set(this.#effect_pending, this.local_pending_count); } }; #effect_pending_subscriber = createSubscriber(() => { - this.#effect_pending = source(this.#local_pending_count); + this.#effect_pending = source(this.local_pending_count); if (DEV) { tag(this.#effect_pending, '$effect.pending()'); @@ -285,13 +285,6 @@ export class Boundary { this.#anchor.before(this.#offscreen_fragment); this.#offscreen_fragment = null; } - - // TODO this feels like a little bit of a kludge, but until we - // overhaul the boundary/batch relationship it's probably - // the most pragmatic solution available to us - queue_micro_task(() => { - Batch.ensure().flush(); - }); } } @@ -304,7 +297,7 @@ export class Boundary { update_pending_count(d) { this.#update_pending_count(d); - this.#local_pending_count += d; + this.local_pending_count += d; effect_pending_updates.add(this.#effect_pending_update); } @@ -363,7 +356,7 @@ export class Boundary { // If the failure happened while flushing effects, current_batch can be null Batch.ensure(); - this.#local_pending_count = 0; + this.local_pending_count = 0; if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 45c78ff926b9..b1e817794939 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -202,10 +202,9 @@ export function unset_context() { export async function async_body(fn) { var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.is_pending(); boundary.update_pending_count(1); - if (!pending) batch.increment(); + batch.increment(); var active = /** @type {Effect} */ (active_effect); @@ -238,12 +237,7 @@ export async function async_body(fn) { } boundary.update_pending_count(-1); - - if (pending) { - batch.flush(); - } else { - batch.decrement(); - } + batch.decrement(); unset_context(); } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index fd2a6d9f5dcf..e76c92119c97 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -11,7 +11,8 @@ import { RENDER_EFFECT, ROOT_EFFECT, MAYBE_DIRTY, - DERIVED + DERIVED, + BOUNDARY_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; @@ -30,6 +31,16 @@ import { invoke_error_boundary } from '../error-handling.js'; import { old_values } from './sources.js'; import { unlink_effect } from './effects.js'; +/** + * @typedef {{ + * parent: EffectTarget | null; + * effect: Effect | null; + * effects: Effect[]; + * render_effects: Effect[]; + * block_effects: Effect[]; + * }} EffectTarget + */ + /** @type {Set} */ const batches = new Set(); @@ -97,26 +108,6 @@ export class Batch { */ #deferred = null; - /** - * Template effects and `$effect.pre` effects, which run when - * a batch is committed - * @type {Effect[]} - */ - #render_effects = []; - - /** - * The same as `#render_effects`, but for `$effect` (which runs after) - * @type {Effect[]} - */ - #effects = []; - - /** - * Block effects, which may need to re-run on subsequent flushes - * in order to update internal sources (e.g. each block items) - * @type {Effect[]} - */ - #block_effects = []; - /** * Deferred effects (which run after async work has completed) that are DIRTY * @type {Effect[]} @@ -155,33 +146,26 @@ export class Batch { if (this.#pending === 0) { // TODO we need this because we commit _then_ flush effects... // maybe there's a way we can reverse the order? - var previous_batch_sources = batch_values; + // var previous_batch_sources = batch_values; this.#commit(); - var render_effects = this.#render_effects; - var effects = this.#effects; - - this.#render_effects = []; - this.#effects = []; - this.#block_effects = []; - // 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; - batch_values = previous_batch_sources; - flush_queued_effects(render_effects); - flush_queued_effects(effects); + // batch_values = previous_batch_sources; + // flush_queued_effects(target.render_effects); + // flush_queued_effects(target.effects); previous_batch = null; this.#deferred?.resolve(); } else { - this.#defer_effects(this.#render_effects); - this.#defer_effects(this.#effects); - this.#defer_effects(this.#block_effects); + // this.#defer_effects(target.render_effects); + // this.#defer_effects(target.effects); + // this.#defer_effects(target.block_effects); } batch_values = null; @@ -195,6 +179,17 @@ export class Batch { #traverse_effect_tree(root) { root.f ^= CLEAN; + var should_defer = false; + + /** @type {EffectTarget} */ + var target = { + parent: null, + effect: null, + effects: [], + render_effects: [], + block_effects: [] + }; + var effect = root.first; while (effect !== null) { @@ -204,15 +199,25 @@ 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: [], + block_effects: [] + }; + } + if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; } else if ((flags & EFFECT) !== 0) { - this.#effects.push(effect); + target.effects.push(effect); } else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) { - this.#render_effects.push(effect); + target.render_effects.push(effect); } else if (is_dirty(effect)) { - if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); + if ((effect.f & BLOCK_EFFECT) !== 0) target.block_effects.push(effect); update_effect(effect); } @@ -228,10 +233,41 @@ export class Batch { effect = effect.next; while (effect === null && parent !== null) { + if (parent.b !== null) { + var ready = parent.b.local_pending_count === 0; + + if (target.parent === null) { + should_defer ||= !ready; + } else if (parent === target.effect) { + if (ready) { + // TODO can this happen? + target.parent.effects.push(...target.effects); + target.parent.render_effects.push(...target.render_effects); + target.parent.block_effects.push(...target.block_effects); + } else { + this.#defer_effects(target.effects); + this.#defer_effects(target.render_effects); + this.#defer_effects(target.block_effects); + } + + target = /** @type {EffectTarget} */ (target.parent); + } + } + effect = parent.next; parent = parent.parent; } } + + if (should_defer) { + this.#defer_effects(target.effects); + this.#defer_effects(target.render_effects); + this.#defer_effects(target.block_effects); + } else { + // TODO append/detach blocks here as well + flush_queued_effects(target.render_effects); + flush_queued_effects(target.effects); + } } /** @@ -245,8 +281,6 @@ export class Batch { // mark as clean so they get scheduled if they depend on pending async state set_signal_status(e, CLEAN); } - - effects.length = 0; } /** diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 6aa9a1d9d920..e9d5edca05a3 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -136,17 +136,14 @@ export function async_derived(fn, location) { if (DEV) current_async_effect = null; var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.is_pending(); if (should_suspend) { boundary.update_pending_count(1); - if (!pending) { - batch.increment(); + batch.increment(); - deferreds.get(batch)?.reject(STALE_REACTION); - deferreds.delete(batch); // delete to ensure correct order in Map iteration below - deferreds.set(batch, d); - } + deferreds.get(batch)?.reject(STALE_REACTION); + deferreds.delete(batch); // delete to ensure correct order in Map iteration below + deferreds.set(batch, d); } /** @@ -156,7 +153,7 @@ export function async_derived(fn, location) { const handler = (value, error = undefined) => { current_async_effect = null; - if (!pending) batch.activate(); + batch.activate(); if (error) { if (error !== STALE_REACTION) { @@ -193,7 +190,7 @@ export function async_derived(fn, location) { if (should_suspend) { boundary.update_pending_count(-1); - if (!pending) batch.decrement(); + batch.decrement(); } }; From 1e2958e5ac70d9f7174068936c487ac0295d3852 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 16:43:29 -0400 Subject: [PATCH 03/15] reinstate kludge --- packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 039eded21bd9..b47857d84303 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -285,6 +285,13 @@ export class Boundary { this.#anchor.before(this.#offscreen_fragment); this.#offscreen_fragment = null; } + + // TODO this feels like a little bit of a kludge, but until we + // overhaul the boundary/batch relationship it's probably + // the most pragmatic solution available to us + queue_micro_task(() => { + Batch.ensure().flush(); + }); } } From ae0038c9536f7d7db0ec05394a1437dcb74b9e60 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 17:04:41 -0400 Subject: [PATCH 04/15] fix test --- packages/svelte/src/internal/client/reactivity/batch.js | 4 ++-- .../tests/runtime-runes/samples/async-abort-signal/_config.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index e76c92119c97..c429b23c98c5 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -160,8 +160,6 @@ export class Batch { // flush_queued_effects(target.effects); previous_batch = null; - - this.#deferred?.resolve(); } else { // this.#defer_effects(target.render_effects); // this.#defer_effects(target.effects); @@ -405,6 +403,8 @@ export class Batch { } batches.delete(this); + + this.#deferred?.resolve(); } increment() { diff --git a/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js index a947a91ab881..af49b1779c01 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js @@ -6,7 +6,7 @@ export default test({ const [reset, resolve] = target.querySelectorAll('button'); reset.click(); - await settled(); + await tick(); assert.deepEqual(logs, ['aborted']); resolve.click(); From 18ecf0138291f4e09a44cfc9a6facd2e683b04d9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 17:28:35 -0400 Subject: [PATCH 05/15] WIP --- .../src/internal/client/reactivity/async.js | 6 ++++-- .../src/internal/client/reactivity/batch.js | 21 ++++++++++++++++--- .../internal/client/reactivity/deriveds.js | 6 ++++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index b1e817794939..a223d1b5beba 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -203,8 +203,10 @@ export async function async_body(fn) { var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); + var blocking = !boundary.is_pending(); + boundary.update_pending_count(1); - batch.increment(); + batch.increment(blocking); var active = /** @type {Effect} */ (active_effect); @@ -237,7 +239,7 @@ export async function async_body(fn) { } boundary.update_pending_count(-1); - batch.decrement(); + batch.decrement(blocking); unset_context(); } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index c429b23c98c5..413cf63284cf 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -101,6 +101,11 @@ export class Batch { */ #pending = 0; + /** + * The number of async effects that are currently in flight, _not_ inside a pending boundary + */ + #blocking_pending = 0; + /** * A deferred that resolves when the batch is committed, used with `settled()` * TODO replace with Promise.withResolvers once supported widely enough @@ -257,7 +262,7 @@ export class Batch { } } - if (should_defer) { + if (this.#blocking_pending > 0) { this.#defer_effects(target.effects); this.#defer_effects(target.render_effects); this.#defer_effects(target.block_effects); @@ -407,12 +412,22 @@ export class Batch { this.#deferred?.resolve(); } - increment() { + /** + * + * @param {boolean} blocking + */ + increment(blocking) { this.#pending += 1; + if (blocking) this.#blocking_pending += 1; } - decrement() { + /** + * + * @param {boolean} blocking + */ + decrement(blocking) { this.#pending -= 1; + if (blocking) this.#blocking_pending -= 1; for (const e of this.#dirty_effects) { set_signal_status(e, DIRTY); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index e9d5edca05a3..1989220abe9c 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -138,8 +138,10 @@ export function async_derived(fn, location) { var batch = /** @type {Batch} */ (current_batch); if (should_suspend) { + var blocking = !boundary.is_pending(); + boundary.update_pending_count(1); - batch.increment(); + batch.increment(blocking); deferreds.get(batch)?.reject(STALE_REACTION); deferreds.delete(batch); // delete to ensure correct order in Map iteration below @@ -190,7 +192,7 @@ export function async_derived(fn, location) { if (should_suspend) { boundary.update_pending_count(-1); - batch.decrement(); + batch.decrement(blocking); } }; From 739f5fc1c6f6c1788894b6880648f19c20261174 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 18:16:21 -0400 Subject: [PATCH 06/15] WIP --- .../src/internal/client/reactivity/batch.js | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 413cf63284cf..1e5bbc3e4aba 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -182,8 +182,6 @@ export class Batch { #traverse_effect_tree(root) { root.f ^= CLEAN; - var should_defer = false; - /** @type {EffectTarget} */ var target = { parent: null, @@ -236,25 +234,15 @@ export class Batch { effect = effect.next; while (effect === null && parent !== null) { - if (parent.b !== null) { - var ready = parent.b.local_pending_count === 0; - - if (target.parent === null) { - should_defer ||= !ready; - } else if (parent === target.effect) { - if (ready) { - // TODO can this happen? - target.parent.effects.push(...target.effects); - target.parent.render_effects.push(...target.render_effects); - target.parent.block_effects.push(...target.block_effects); - } else { - this.#defer_effects(target.effects); - this.#defer_effects(target.render_effects); - this.#defer_effects(target.block_effects); - } - - target = /** @type {EffectTarget} */ (target.parent); - } + 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); + this.#defer_effects(target.block_effects); + + target = /** @type {EffectTarget} */ (target.parent); } effect = parent.next; From d8df737f56e4cca980409360959137be9208e4d2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 19:50:10 -0400 Subject: [PATCH 07/15] WIP --- .../src/internal/client/reactivity/batch.js | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 1e5bbc3e4aba..ce58d05087b9 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -143,8 +143,17 @@ export class Batch { this.apply(); + /** @type {EffectTarget} */ + var target = { + parent: null, + effect: null, + effects: [], + render_effects: [], + block_effects: [] + }; + for (const root of root_effects) { - this.#traverse_effect_tree(root); + this.#traverse_effect_tree(root, target); } // if there is no outstanding async work, commit @@ -155,22 +164,33 @@ export class Batch { this.#commit(); - // 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; - // batch_values = previous_batch_sources; // flush_queued_effects(target.render_effects); // flush_queued_effects(target.effects); - - previous_batch = null; } else { // this.#defer_effects(target.render_effects); // this.#defer_effects(target.effects); // this.#defer_effects(target.block_effects); } + if (this.#blocking_pending > 0) { + this.#defer_effects(target.effects); + this.#defer_effects(target.render_effects); + this.#defer_effects(target.block_effects); + } else { + // TODO append/detach blocks here as well, not in #commit + + // 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); + + previous_batch = null; + } + batch_values = null; } @@ -178,19 +198,11 @@ export class Batch { * Traverse the effect tree, executing effects or stashing * them for later execution as appropriate * @param {Effect} root + * @param {EffectTarget} target */ - #traverse_effect_tree(root) { + #traverse_effect_tree(root, target) { root.f ^= CLEAN; - /** @type {EffectTarget} */ - var target = { - parent: null, - effect: null, - effects: [], - render_effects: [], - block_effects: [] - }; - var effect = root.first; while (effect !== null) { @@ -249,16 +261,6 @@ export class Batch { parent = parent.parent; } } - - if (this.#blocking_pending > 0) { - this.#defer_effects(target.effects); - this.#defer_effects(target.render_effects); - this.#defer_effects(target.block_effects); - } else { - // TODO append/detach blocks here as well - flush_queued_effects(target.render_effects); - flush_queued_effects(target.effects); - } } /** @@ -343,6 +345,15 @@ export class Batch { let is_earlier = true; + /** @type {EffectTarget} */ + var dummy_target = { + parent: null, + effect: null, + effects: [], + render_effects: [], + block_effects: [] + }; + for (const batch of batches) { if (batch === this) { is_earlier = false; @@ -383,9 +394,11 @@ export class Batch { batch.apply(); for (const root of queued_root_effects) { - batch.#traverse_effect_tree(root); + batch.#traverse_effect_tree(root, dummy_target); } + // TODO do we need to do anything with `target`? defer block effects? + queued_root_effects = []; batch.deactivate(); } From c054cd4f3f419745624d7dcd537c48f506b449f8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 20:10:28 -0400 Subject: [PATCH 08/15] remove kludge --- packages/svelte/src/internal/client/dom/blocks/async.js | 6 ++++++ packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 ------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 6df0739918ed..a1f6c6a308e5 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,5 +1,6 @@ /** @import { TemplateNode, Value } from '#client' */ import { flatten } from '../../reactivity/async.js'; +import { Batch, current_batch } from '../../reactivity/batch.js'; import { get } from '../../runtime.js'; import { hydrate_next, @@ -18,8 +19,12 @@ import { get_boundary } from './boundary.js'; */ export function async(node, expressions, 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 was_hydrating = hydrating; @@ -44,6 +49,7 @@ export function async(node, expressions, fn) { fn(node, ...values); } finally { boundary.update_pending_count(-1); + batch.decrement(blocking); } if (was_hydrating) { diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index b47857d84303..039eded21bd9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -285,13 +285,6 @@ export class Boundary { this.#anchor.before(this.#offscreen_fragment); this.#offscreen_fragment = null; } - - // TODO this feels like a little bit of a kludge, but until we - // overhaul the boundary/batch relationship it's probably - // the most pragmatic solution available to us - queue_micro_task(() => { - Batch.ensure().flush(); - }); } } From bba5314dab7c3832bdce0c4a484bb9b20e4ac887 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 20:17:13 -0400 Subject: [PATCH 09/15] restore batch_values after commit --- packages/svelte/src/internal/client/reactivity/batch.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ce58d05087b9..b7c78b519660 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -343,7 +343,8 @@ export class Batch { if (batches.size > 1) { this.#previous.clear(); - let is_earlier = true; + var previous_batch_values = batch_values; + var is_earlier = true; /** @type {EffectTarget} */ var dummy_target = { @@ -406,6 +407,7 @@ export class Batch { } current_batch = null; + batch_values = previous_batch_values; } batches.delete(this); From 46d61eeec65df4a9c94721de1ff52a3d12b8240a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 21:31:57 -0400 Subject: [PATCH 10/15] make private --- .../svelte/src/internal/client/dom/blocks/boundary.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 039eded21bd9..4945cc16d0ad 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -87,7 +87,7 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; - local_pending_count = 0; + #local_pending_count = 0; #pending_count = 0; #is_creating_fallback = false; @@ -103,12 +103,12 @@ export class Boundary { #effect_pending_update = () => { if (this.#effect_pending) { - internal_set(this.#effect_pending, this.local_pending_count); + internal_set(this.#effect_pending, this.#local_pending_count); } }; #effect_pending_subscriber = createSubscriber(() => { - this.#effect_pending = source(this.local_pending_count); + this.#effect_pending = source(this.#local_pending_count); if (DEV) { tag(this.#effect_pending, '$effect.pending()'); @@ -297,7 +297,7 @@ export class Boundary { update_pending_count(d) { this.#update_pending_count(d); - this.local_pending_count += d; + this.#local_pending_count += d; effect_pending_updates.add(this.#effect_pending_update); } @@ -356,7 +356,7 @@ export class Boundary { // If the failure happened while flushing effects, current_batch can be null Batch.ensure(); - this.local_pending_count = 0; + this.#local_pending_count = 0; if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { From 90e3148a562e7d2f546cf80040a83c02e6920ce2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 21:33:42 -0400 Subject: [PATCH 11/15] tidy up --- .../src/internal/client/reactivity/batch.js | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b7c78b519660..b74ce0ba9b2e 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -158,19 +158,9 @@ export class Batch { // if there is no outstanding async work, commit if (this.#pending === 0) { - // TODO we need this because we commit _then_ flush effects... - // maybe there's a way we can reverse the order? - // var previous_batch_sources = batch_values; - + // commit before flushing effects, since that may result in + // another batch being created this.#commit(); - - // batch_values = previous_batch_sources; - // flush_queued_effects(target.render_effects); - // flush_queued_effects(target.effects); - } else { - // this.#defer_effects(target.render_effects); - // this.#defer_effects(target.effects); - // this.#defer_effects(target.block_effects); } if (this.#blocking_pending > 0) { @@ -178,7 +168,7 @@ export class Batch { this.#defer_effects(target.render_effects); this.#defer_effects(target.block_effects); } else { - // TODO append/detach blocks here as well, not in #commit + // TODO append/detach blocks here, not in #commit // 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. From e31956de853e90cc02a4925222e8baa405e42494 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 17:58:37 -0400 Subject: [PATCH 12/15] fix tests --- .../samples/async-binding-update-while-focused-2/main.svelte | 2 +- .../samples/async-binding-update-while-focused-3/main.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte index e2f01a66c892..c0e4d862a8e5 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte @@ -12,7 +12,7 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte index 566ea60ec5cf..8f5e2862eb05 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte @@ -12,7 +12,7 @@ From fdc511416e553032f4d5967e02e73b7563d936e8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 20:01:21 -0400 Subject: [PATCH 13/15] update test --- .../samples/async-block-reject-each-during-init/_config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js b/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js index c5dae7fee294..ca5fd9ca8974 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js @@ -24,5 +24,7 @@ export default test({

1

` ); - } + }, + + expect_unhandled_rejections: true }); From db41cc46cb85d24e1bc15da334f028314b059b5a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 20:14:22 -0400 Subject: [PATCH 14/15] reset #dirty_effects and #maybe_dirty_effects --- packages/svelte/src/internal/client/reactivity/batch.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a9973b465b45..215b992cc419 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -433,6 +433,9 @@ export class Batch { schedule_effect(e); } + this.#dirty_effects = []; + this.#maybe_dirty_effects = []; + this.flush(); } From cfa87a9c3762823a509fd38563391556dfbc2d88 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 20:47:20 -0400 Subject: [PATCH 15/15] add test --- .../async-effect-after-boundary/Child.svelte | 5 +++++ .../async-effect-after-boundary/_config.js | 16 ++++++++++++++ .../async-effect-after-boundary/main.svelte | 22 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte new file mode 100644 index 000000000000..65a225431b18 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js new file mode 100644 index 000000000000..f7b6c513d4cc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [shift] = target.querySelectorAll('button'); + + await tick(); + assert.deepEqual(logs, []); + + shift.click(); + await tick(); + + assert.deepEqual(logs, ['in effect']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte new file mode 100644 index 000000000000..edfd3c4d10ac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte @@ -0,0 +1,22 @@ + + + + + +

{await push('hello')}

+ + + {#snippet pending()} +

loading...

+ {/snippet} +