-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix: $effect.pending sends updates to incorrect boundary
#16729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||
| } 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; | ||||||
|
|
||||||
|
|
@@ -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}. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is the |
||||||
| */ | ||||||
| #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; | ||||||
|
|
||||||
| /** | ||||||
|
|
@@ -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), () => { | ||||||
|
|
@@ -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; | ||||||
|
|
@@ -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); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. huh? this can't possibly be right
Suggested change
|
||||||
|
|
||||||
| if (this.#pending_count === 0) { | ||||||
| if (this.#cascading_pending_count === 0) { | ||||||
| this.pending = false; | ||||||
|
|
||||||
| if (this.#pending_effect) { | ||||||
|
|
@@ -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); | ||||||
|
|
@@ -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(); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||
|
|
||||||
| 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; | ||||||
|
|
@@ -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(); | ||||||
| } | ||||||
|
|
||||||
| 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> | ||
|
|
||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
set pendinglogic feels extremely suspicious to me. In all cases where we're setting it totrue, it's because the boundary in question has a pending snippet. Why would we ever want to set a parent boundary'spendingstate tofalse? If we comment out this line, no test fails. Just all feels wrong