diff --git a/.changeset/dirty-cycles-smash.md b/.changeset/dirty-cycles-smash.md new file mode 100644 index 0000000000..1b031cf0af --- /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 82f107ab29..5ec50a5988 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 12ca547608..b7f1803782 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; @@ -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()'); @@ -125,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; @@ -156,7 +157,7 @@ export class Boundary { this.#pending_effect = null; }); - this.pending = false; + this.#pending = false; } }); } else { @@ -169,7 +170,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.pending = false; + this.#pending = false; } } }, flags); @@ -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,12 +229,25 @@ 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; + } + + e.await_outside_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, () => { @@ -240,14 +262,16 @@ 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) { - 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); } @@ -308,7 +332,7 @@ export class Boundary { }); } - this.pending = true; + this.#pending = true; this.#main_effect = this.#run(() => { this.#is_creating_fallback = false; @@ -318,7 +342,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.pending = false; + this.#pending = false; } }; @@ -384,12 +408,8 @@ function move_effect(effect, fragment) { } } -export function get_pending_boundary() { - var boundary = /** @type {Effect} */ (active_effect).b; - - while (boundary !== null && !boundary.has_pending_snippet()) { - boundary = boundary.parent; - } +export function get_boundary() { + const boundary = /** @type {Effect} */ (active_effect).b; 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 65d004137f..b7a5d5cdb7 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 82f1de67a9..5176a4f74b 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, @@ -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); @@ -668,9 +671,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 31dc267960..299251a2dc 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 0000000000..9fe354bac0 --- /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 0000000000..eeafbdc3c4 --- /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} +