Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dirty-cycles-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: send `$effect.pending` count to the correct boundary
4 changes: 2 additions & 2 deletions packages/svelte/src/internal/client/dom/blocks/async.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/** @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
* @param {Array<() => Promise<any>>} expressions
* @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);

Expand Down
95 changes: 75 additions & 20 deletions packages/svelte/src/internal/client/dom/blocks/boundary.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,36 @@ export function boundary(node, props, children) {
}

export class Boundary {
pending = false;

/** @type {Boundary | null} */
parent;

/**
* Whether this boundary is inside a boundary (including this one) that's showing a pending snippet.
* TODO the `returns` annotation is probably incorrect (it's a getter) but TSGo is currently failing
* @type {boolean}
* @returns {boolean}
*/
get pending() {
if (this.has_pending_snippet()) {
return this.#pending;
}

// intentionally not throwing here, as the answer to "am I in a pending snippet" is false when
// there's no pending snippet at all
return this.parent?.pending ?? false;
}

set pending(value) {
if (this.has_pending_snippet()) {
this.#pending = value;
} else if (this.parent) {
this.parent.pending = value;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The set pending logic feels extremely suspicious to me. In all cases where we're setting it to true, it's because the boundary in question has a pending snippet. Why would we ever want to set a parent boundary's pending state to false? If we comment out this line, no test fails. Just all feels wrong

} else if (value) {
e.await_outside_boundary();
}
// if we're trying to set it to `false` and yeeting that into the void, it's fine
}

/** @type {TemplateNode} */
#anchor;

Expand Down Expand Up @@ -81,7 +106,28 @@ export class Boundary {
/** @type {DocumentFragment | null} */
#offscreen_fragment = null;

/**
* Whether this boundary is inside a boundary (including this one) that's showing a pending snippet.
* Derived from {@link props.pending} and {@link cascading_pending_count}.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That "derived from ..." part is confusing because that's not what's happening.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the @link syntax meant to do?

*/
#pending = false;

/**
* The number of pending async deriveds/expressions within this boundary, not counting any parent or child boundaries.
* This controls `$effect.pending` for this boundary.
*
* Don't ever set this directly; use {@link update_pending_count} instead.
*/
#pending_count = 0;

/**
* Like {@link #pending_count}, but treats boundaries with no `pending` snippet as porous.
* This controls the pending snippet for this boundary.
*
* Don't ever set this directly; use {@link update_pending_count} instead.
*/
#cascading_pending_count = 0;

#is_creating_fallback = false;

/**
Expand Down Expand Up @@ -149,7 +195,7 @@ export class Boundary {
return branch(() => this.#children(this.#anchor));
});

if (this.#pending_count > 0) {
if (this.#cascading_pending_count > 0) {
this.#show_pending_snippet();
} else {
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
Expand All @@ -166,7 +212,7 @@ export class Boundary {
this.error(error);
}

if (this.#pending_count > 0) {
if (this.#cascading_pending_count > 0) {
this.#show_pending_snippet();
} else {
this.pending = false;
Expand Down Expand Up @@ -220,11 +266,11 @@ export class Boundary {
}
}

/** @param {1 | -1} d */
#update_pending_count(d) {
this.#pending_count += d;
/** @param {number} d */
#update_cascading_pending_count(d) {
this.#cascading_pending_count = Math.max(this.#cascading_pending_count + d, 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh? this can't possibly be right

Suggested change
this.#cascading_pending_count = Math.max(this.#cascading_pending_count + d, 0);
this.#cascading_pending_count += d;


if (this.#pending_count === 0) {
if (this.#cascading_pending_count === 0) {
this.pending = false;

if (this.#pending_effect) {
Expand All @@ -240,12 +286,21 @@ export class Boundary {
}
}

/** @param {1 | -1} d */
update_pending_count(d) {
/**
* @param {number} d
* @param {boolean} safe
*/
update_pending_count(d, safe = false, first = true) {
if (first) {
this.#pending_count = Math.max(this.#pending_count + d, 0);
}

if (this.has_pending_snippet()) {
this.#update_pending_count(d);
this.#update_cascading_pending_count(d);
} else if (this.parent) {
this.parent.#update_pending_count(d);
this.parent.update_pending_count(d, safe, false);
} else if (this.parent === null && !safe) {
e.await_outside_boundary();
}

effect_pending_updates.add(this.#effect_pending_update);
Expand Down Expand Up @@ -300,22 +355,26 @@ export class Boundary {
// If the failure happened while flushing effects, current_batch can be null
Batch.ensure();

this.#pending_count = 0;
// this ensures we modify the cascading_pending_count of the correct parent
// by the number we're decreasing this boundary by
this.update_pending_count(-this.#pending_count, true);

if (this.#failed_effect !== null) {
pause_effect(this.#failed_effect, () => {
this.#failed_effect = null;
});
}

this.pending = true;
// 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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think this is quite right, since this uses the getter which will set the parent to true if the current one doesn't have a pending snippet. Did you mean to do this?

Suggested change
this.pending = this.has_pending_snippet();
this.#pending = this.has_pending_snippet();


this.#main_effect = this.#run(() => {
this.#is_creating_fallback = false;
return branch(() => this.#children(this.#anchor));
});

if (this.#pending_count > 0) {
if (this.#cascading_pending_count > 0) {
this.#show_pending_snippet();
} else {
this.pending = false;
Expand Down Expand Up @@ -384,13 +443,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();
}
Expand Down
4 changes: 1 addition & 3 deletions packages/svelte/src/internal/client/reactivity/async.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
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 { invoke_error_boundary } from '../error-handling.js';
import {
active_effect,
Expand Down Expand Up @@ -39,7 +38,6 @@ export function flatten(sync, async, fn) {
var parent = /** @type {Effect} */ (active_effect);

var restore = capture();
var boundary = get_pending_boundary();

Promise.all(async.map((expression) => async_derived(expression)))
.then((result) => {
Expand All @@ -60,7 +58,7 @@ export function flatten(sync, async, fn) {
unset_context();
})
.catch((error) => {
boundary.error(error);
invoke_error_boundary(error, parent);
});
}

Expand Down
5 changes: 2 additions & 3 deletions packages/svelte/src/internal/client/reactivity/batch.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ import {
INERT,
RENDER_EFFECT,
ROOT_EFFECT,
USER_EFFECT,
MAYBE_DIRTY
} 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,
Expand Down Expand Up @@ -668,7 +667,7 @@ 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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
`
<button>increment</button>
<button>shift</button>
<p>loading...</p>
`
);

shift.click();
shift.click();
shift.click();

await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 0</p>
<p>outer pending: 0</p>
`
);

increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 3</p>
<p>outer pending: 0</p>
`
);

shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 2</p>
<p>outer pending: 0</p>
`
);

shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 1</p>
<p>outer pending: 0</p>
`
);

shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>1</p>
<p>1</p>
<p>1</p>
<p>inner pending: 0</p>
<p>outer pending: 0</p>
`
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script>
let value = $state(0);
let deferreds = [];

function push(value) {
const deferred = Promise.withResolvers();
deferreds.push({ value, deferred });
return deferred.promise;
}

function shift() {
const d = deferreds.shift();
d?.deferred.resolve(d.value);
}
</script>

<button onclick={() => value++}>increment</button>
<button onclick={() => shift()}>shift</button>

<svelte:boundary>
<svelte:boundary>
<p>{await push(value)}</p>
<p>{await push(value)}</p>
<p>{await push(value)}</p>
<p>inner pending: {$effect.pending()}</p>
</svelte:boundary>
<p>outer pending: {$effect.pending()}</p>

{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>


Loading