From 128e158fa8293a2471db79f745c70686c0cafad7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 09:57:54 -0400 Subject: [PATCH 1/5] fix: send `$effect.pending` count to the correct boundary --- .changeset/dirty-cycles-smash.md | 5 + .../src/internal/client/dom/blocks/async.js | 4 +- .../internal/client/dom/blocks/boundary.js | 40 +++++--- .../src/internal/client/reactivity/async.js | 4 +- .../src/internal/client/reactivity/batch.js | 6 +- .../internal/client/reactivity/deriveds.js | 2 +- .../async-effect-pending-nested/_config.js | 95 +++++++++++++++++++ .../async-effect-pending-nested/main.svelte | 34 +++++++ 8 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 .changeset/dirty-cycles-smash.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte diff --git a/.changeset/dirty-cycles-smash.md b/.changeset/dirty-cycles-smash.md new file mode 100644 index 000000000000..1b031cf0af95 --- /dev/null +++ b/.changeset/dirty-cycles-smash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: send `$effect.pending` count to the correct boundary diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 82f107ab29a1..5ec50a598882 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,7 +1,7 @@ /** @import { TemplateNode, Value } from '#client' */ import { flatten } from '../../reactivity/async.js'; import { get } from '../../runtime.js'; -import { get_pending_boundary } from './boundary.js'; +import { get_boundary } from './boundary.js'; /** * @param {TemplateNode} node @@ -9,7 +9,7 @@ import { get_pending_boundary } from './boundary.js'; * @param {(anchor: TemplateNode, ...deriveds: Value[]) => void} fn */ export function async(node, expressions, fn) { - var boundary = get_pending_boundary(); + var boundary = get_boundary(); boundary.update_pending_count(1); diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 12ca54760811..aae369f4cd3b 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -81,6 +81,7 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; + #local_pending_count = 0; #pending_count = 0; #is_creating_fallback = false; @@ -95,12 +96,12 @@ export class Boundary { #effect_pending_update = () => { if (this.#effect_pending) { - internal_set(this.#effect_pending, this.#pending_count); + internal_set(this.#effect_pending, this.#local_pending_count); } }; #effect_pending_subscriber = createSubscriber(() => { - this.#effect_pending = source(this.#pending_count); + this.#effect_pending = source(this.#local_pending_count); if (DEV) { tag(this.#effect_pending, '$effect.pending()'); @@ -179,6 +180,14 @@ export class Boundary { } } + /** + * Returns `true` 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()); + } + has_pending_snippet() { return !!this.#props.pending; } @@ -220,8 +229,20 @@ export class Boundary { } } - /** @param {1 | -1} d */ + /** + * Updates the pending count associated with the currently visible pending snippet, + * if any, such that we can replace the snippet with content once work is done + * @param {1 | -1} d + */ #update_pending_count(d) { + if (!this.has_pending_snippet()) { + if (this.parent) { + this.parent.#update_pending_count(d); + } + + return; + } + this.#pending_count += d; if (this.#pending_count === 0) { @@ -242,12 +263,9 @@ export class Boundary { /** @param {1 | -1} d */ update_pending_count(d) { - if (this.has_pending_snippet()) { - this.#update_pending_count(d); - } else if (this.parent) { - this.parent.#update_pending_count(d); - } + this.#update_pending_count(d); + this.#local_pending_count += d; effect_pending_updates.add(this.#effect_pending_update); } @@ -384,13 +402,9 @@ function move_effect(effect, fragment) { } } -export function get_pending_boundary() { +export function get_boundary() { var boundary = /** @type {Effect} */ (active_effect).b; - while (boundary !== null && !boundary.has_pending_snippet()) { - boundary = boundary.parent; - } - if (boundary === null) { e.await_outside_boundary(); } diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 65d004137fcb..b7a5d5cdb7c0 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -3,7 +3,7 @@ import { DESTROYED } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, is_runes, set_component_context } from '../context.js'; -import { get_pending_boundary } from '../dom/blocks/boundary.js'; +import { get_boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -39,7 +39,7 @@ export function flatten(sync, async, fn) { var parent = /** @type {Effect} */ (active_effect); var restore = capture(); - var boundary = get_pending_boundary(); + var boundary = get_boundary(); Promise.all(async.map((expression) => async_derived(expression))) .then((result) => { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 82f1de67a98e..e700ddb4e066 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -15,7 +15,7 @@ import { } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; -import { get_pending_boundary } from '../dom/blocks/boundary.js'; +import { get_boundary } from '../dom/blocks/boundary.js'; import { active_effect, is_dirty, @@ -668,9 +668,9 @@ export function schedule_effect(signal) { } export function suspend() { - var boundary = get_pending_boundary(); + var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.pending; + var pending = boundary.is_pending(); boundary.update_pending_count(1); if (!pending) batch.increment(); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 31dc26796099..299251a2dcdd 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -135,7 +135,7 @@ export function async_derived(fn, location) { prev = promise; var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.pending; + var pending = boundary.is_pending(); if (should_suspend) { boundary.update_pending_count(1); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js new file mode 100644 index 000000000000..9fe354bac084 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js @@ -0,0 +1,95 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, shift] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + +

loading...

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

0

+

0

+

0

+

inner pending: 0

+

outer pending: 0

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

0

+

0

+

0

+

inner pending: 3

+

outer pending: 0

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

0

+

0

+

0

+

inner pending: 2

+

outer pending: 0

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

0

+

0

+

0

+

inner pending: 1

+

outer pending: 0

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

1

+

1

+

1

+

inner pending: 0

+

outer pending: 0

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte new file mode 100644 index 000000000000..eeafbdc3c492 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte @@ -0,0 +1,34 @@ + + + + + + + +

{await push(value)}

+

{await push(value)}

+

{await push(value)}

+

inner pending: {$effect.pending()}

+
+

outer pending: {$effect.pending()}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
+ + From 95400b567e9a7e650b94c6450c19e466be34e9a7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 10:00:52 -0400 Subject: [PATCH 2/5] make boundary.pending private, use boundary.is_pending consistently --- .../src/internal/client/dom/blocks/boundary.js | 18 +++++++++--------- .../src/internal/client/reactivity/batch.js | 5 ++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index aae369f4cd3b..d1d7fb0eedc4 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -49,11 +49,11 @@ export function boundary(node, props, children) { } export class Boundary { - pending = false; - /** @type {Boundary | null} */ parent; + #pending = false; + /** @type {TemplateNode} */ #anchor; @@ -126,7 +126,7 @@ export class Boundary { this.parent = /** @type {Effect} */ (active_effect).b; - this.pending = !!this.#props.pending; + this.#pending = !!this.#props.pending; this.#effect = block(() => { /** @type {Effect} */ (active_effect).b = this; @@ -157,7 +157,7 @@ export class Boundary { this.#pending_effect = null; }); - this.pending = false; + this.#pending = false; } }); } else { @@ -170,7 +170,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.pending = false; + this.#pending = false; } } }, flags); @@ -185,7 +185,7 @@ export class Boundary { * @returns {boolean} */ is_pending() { - return this.pending || (!!this.parent && this.parent.is_pending()); + return this.#pending || (!!this.parent && this.parent.is_pending()); } has_pending_snippet() { @@ -246,7 +246,7 @@ export class Boundary { this.#pending_count += d; if (this.#pending_count === 0) { - this.pending = false; + this.#pending = false; if (this.#pending_effect) { pause_effect(this.#pending_effect, () => { @@ -326,7 +326,7 @@ export class Boundary { }); } - this.pending = true; + this.#pending = true; this.#main_effect = this.#run(() => { this.#is_creating_fallback = false; @@ -336,7 +336,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.pending = false; + this.#pending = false; } }; diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index e700ddb4e066..5176a4f74b0d 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -298,7 +298,10 @@ export class Batch { this.#render_effects.push(effect); } else if ((flags & CLEAN) === 0) { if ((flags & ASYNC) !== 0) { - var effects = effect.b?.pending ? this.#boundary_async_effects : this.#async_effects; + var effects = effect.b?.is_pending() + ? this.#boundary_async_effects + : this.#async_effects; + effects.push(effect); } else if (is_dirty(effect)) { if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); From 26e4a4c9bb3472e102bbbd32019c70829abe0bda Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 10:11:47 -0400 Subject: [PATCH 3/5] move error to correct place --- .../svelte/src/internal/client/dom/blocks/boundary.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index d1d7fb0eedc4..5c19a8d84a4d 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -238,9 +238,10 @@ export class Boundary { if (!this.has_pending_snippet()) { if (this.parent) { this.parent.#update_pending_count(d); + return; } - return; + e.await_outside_boundary(); } this.#pending_count += d; @@ -403,13 +404,7 @@ function move_effect(effect, fragment) { } export function get_boundary() { - var boundary = /** @type {Effect} */ (active_effect).b; - - if (boundary === null) { - e.await_outside_boundary(); - } - - return boundary; + return /** @type {Effect} */ (active_effect).b; } export function pending() { From 84182987264b5a73c34674c3cfc6be941493818f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 10:19:58 -0400 Subject: [PATCH 4/5] we need that error --- .../svelte/src/internal/client/dom/blocks/boundary.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 5c19a8d84a4d..c31a18ef595c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -404,7 +404,13 @@ function move_effect(effect, fragment) { } export function get_boundary() { - return /** @type {Effect} */ (active_effect).b; + const boundary = /** @type {Effect} */ (active_effect).b; + + if (boundary === null) { + e.await_outside_boundary(); + } + + return boundary; } export function pending() { From 545903f743d2b45f23c63ee632b42617cf9a5bdd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 10:21:15 -0400 Subject: [PATCH 5/5] update JSDoc --- packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index c31a18ef595c..b7f180378270 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -262,7 +262,12 @@ export class Boundary { } } - /** @param {1 | -1} d */ + /** + * Update the source that powers `$effect.pending()` inside this boundary, + * and controls when the current `pending` snippet (if any) is removed. + * Do not call from inside the class + * @param {1 | -1} d + */ update_pending_count(d) { this.#update_pending_count(d);