fix: perf regression with async mode#17461
Conversation
🦋 Changeset detectedLatest commit: 76cd242 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
a29aa87 to
47e6efa
Compare
47e6efa to
d5b2ecb
Compare
|
Although my initial approach to the async tracking fix worked, it caused boundaries in a particular component of mine to get stuck on pending. Can't quite reproduce it in a test, but I've refactored to only record promises as settled after decrement which takes care of that bug. |
|
This appears to completely fix the significant performance issues my projects have been having since Svelte >5.43.3 with components with LayerChart; thank you! |
|
Great work @hmnd! I tested this locally against the new LayerChart docs and see a significant improvement for many examples (especially Choropleth and Bubble map). Thanks! The spike map example is still very slow compared to pre-async even with this PR, but I'll reevaluate more closely and report back after this PR hits an official release (and see if I find any other examples). I appreciate all your work, and thanks again! |
02a9e13 to
27dbad8
Compare
|
@techniq Could you give my latest push a try? I think it solves for the spike map now too. Performance doesn't look completely on the same level of next.layerchart.com, but pretty close! Also, if comparing against the deployed next.layerchart.com, try it with pnpm preview or |
|
I was seeing a huge number of flushes happening, due to decrement being called on each Though the children of the each block being in an Profiling on layerchart docs, it looks like this finally brings things down to pre-async levels, with rendering the |
I was playing around with this branch, trying to understand how we end up in situations where we're awaiting a promise that's already resolved, and it seems to be very rare — with this diff... diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js
index fc04894772..d0183ac3ed 100644
--- a/packages/svelte/src/internal/client/dom/blocks/async.js
+++ b/packages/svelte/src/internal/client/dom/blocks/async.js
@@ -20,6 +20,7 @@ import { get_boundary } from './boundary.js';
*/
export function async(node, blockers = [], expressions = [], fn) {
if (expressions.length === 0 && blockers.length > 0 && blockers.every(is_promise_settled)) {
+ throw new Error('here');
fn(node);
return;
}...no tests fail, though you can reach that case like so: <script>
import Component from './Component.svelte';
await delayed(0, 500);
let x = 1;
</script>
<Component {x}>
<Component {x} />
</Component>With this diff... diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 6ee97b86c3..0a13566cac 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -47,6 +47,10 @@ export function flatten(blockers, sync, async, fn) {
// Filter out already-settled blockers - no need to wait for them
var pending = blockers.filter((b) => !is_promise_settled(b));
+ if (pending.length < blockers.length) {
+ throw new Error('here');
+ }
+
if (async.length === 0 && pending.length === 0) {
fn(sync.map(d));
return;...we get 3 distinct failed tests, such as <script>
let checked = $state(false);
const foo = $derived(await checked);
</script>
<input type="checkbox" bind:checked />
{#each checked === foo && [1]}
<p>{checked}</p>
{/each}The template effect inside the each block is waiting for the diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index ef0b35f560..63d63020c6 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -1032,6 +1032,12 @@ function calculate_blockers(instance, scopes, analysis) {
const rune = get_rune(node, context.state.scope);
if (rune === '$effect') return;
+ // deriveds do not mutate their inputs
+ if (rune === '$derived' || rune === '$derived.by') {
+ context.next();
+ return;
+ }
+
/** @type {Set<Binding>} */
const touched = new Set();
touch(node, context.state.scope, touched);...the test passes. (Might open a branch for that; it applies to other runes too, though when I tried doing it for all runes it caused separate failures which I haven't yet investigated.) All of which is to say that it seems at least possible that this is enough of an edge case that the additional complexity isn't warranted. (I tend to be wary of using Either way I do think it could be worth us tweaking the compiler to make these cases even rarer. The previous diff is one such example. Another example would be $.async(node_1, [$$promises[1]], [() => color], (node_1, $$condition) => {
var consequent_1 = ($$anchor) => {
// ...
$.template_effect(
() => // ...,
void 0,
void 0,
[$$promises[1], $$promises[2]]
);
// ...
};
// ...
});It seems unnecessary to await For now I'll take a closer look at #17511, since it seems like that's where the bigger wins are. Thank you for digging into this!
D'oh — I somehow contorted myself into thinking that wouldn't be a problem, because the promise wouldn't get reassigned until the previous one had resolved? Which... is not how this works at all 😆 |
Out of curiosity what test case are you using for this? The LayerChart REPL in #17176 (comment) doesn't appear to be improved by either this or #17511 |
|
Happy to bo of some help :) Yeah, I alluded to the unnecessary awaiting earlier in #17461 (comment) Here's an example from the layerchart spike-map page (omitting a bunch of irrelevant bits for readability). $.async(node, [$$promises[3], $$promises[12]], void 0, ($$anchor) => {
const children = $.wrap_snippet(Spike_map, function ($$anchor, $$arg0) {
$.validate_snippet_args(...arguments);
let context = () => $$arg0?.().context;
context();
const strokeWidth = $.tag($.derived(() => 1 / context().transform.scale), 'strokeWidth');
$.get(strokeWidth);
var fragment_1 = root_1();
var node_1 = $.first_child(fragment_1);
$.add_svelte_meta(() => TransformContextControls(node_1, {}), 'component', Spike_map, 71, 2, { componentTag: 'TransformContextControls' });
var node_2 = $.sibling(node_1, 2);
$.add_svelte_meta(
() => Layer(node_2, {
children: $.wrap_snippet(Spike_map, ($$anchor, $$slotProps) => {
var fragment_2 = root_2();
var node_3 = $.first_child(fragment_2);
$.async(node_3, [$$promises[12]], void 0, ($$anchor) => {
$.add_svelte_meta(
() => GeoPath(node_3, {
get geojson() {
return states;
},
class: 'fill-surface-content/10 stroke-surface-100',
get strokeWidth() {
return $.get(strokeWidth);
}
}),
'component',
Spike_map,
74,
3,
{ componentTag: 'GeoPath' }
);
});
var node_4 = $.sibling(node_3, 2);
$.async(node_4, [$$promises[12]], [() => enrichedCountiesFeatures], (node_4, $$collection) => {
$.add_svelte_meta(
() => $.each(node_4, 17, () => $.get($$collection), $.index, ($$anchor, feature, $$index, $$array) => {
var fragment_3 = $.comment();
var node_5 = $.first_child(fragment_3);
...
$.append($$anchor, fragment_3);
}),
'each',
Spike_map,
76,
3
);
});
var node_7 = $.sibling(node_4, 2);
$.async(node_7, [$$promises[12]], [() => enrichedCountiesFeatures], (node_7, $$collection) => {
$.add_svelte_meta(
() => $.each(node_7, 17, () => $.get($$collection), $.index, ($$anchor, feature, $$index_1, $$array_1) => {
...
}),
'each',
Spike_map,
91,
3
);
});
$.append($$anchor, fragment_2);
}),I don't think anything within the top-level |
|
I'm running the docs-v2 branch of https://github.com/techniq/layerchart |
|
Run the following in the docs folder: |
|
Though really all you should need is a mix of top-level await returning a large array + each over it, containing an async derived referring back to the await. See: https://github.com/techniq/layerchart/blob/docs-v2/docs/src/examples/components/GeoPath/spike-map.svelte |
Actually I take it back — with this test I see very significant wins over <script>
import { tick } from 'svelte';
let condition = $state(false);
await 1;
let array = Array(20000);
let message = 'hello';
</script>
<button onclick={() => {
condition = !condition;
if (condition) {
console.time('render');
tick().then(() => {
console.timeEnd('render');
});
}
}}>
toggle
</button>
{#if condition}
{#each array}
{#if true}
<p>{message}</p>
{/if}
{/each}
{/if}I'll give the |
|
Maybe related - I seem to remember there being a hot path in |
|
Merged #17519 into this, rather than merging that branch. As mentioned over there I suspect the Will still look out for ways to make the compiler smarter so that we hit the case where we need this logic less often. This is a great improvement, thanks @hmnd! |
PR sveltejs#17461 added the `wait` function to `reactivity/async.js` and modified the compiler to generate `$.wait()` calls, but forgot to export it from `index.js`. This causes a runtime error when using `await` inside `$derived()` with `experimental.async: true`: ``` TypeError: $.wait is not a function ``` Fixes sveltejs#17529
PR sveltejs#17461 added the `wait` function to `reactivity/async.js` and modified the compiler to generate `$.wait()` calls, but forgot to export it from `index.js`. This causes a runtime error when using multiple `await` expressions inside `$derived()` with `experimental.async: true`: ``` TypeError: $.wait is not a function ``` Fixes sveltejs#17529
PR sveltejs#17461 added the `wait` function to `reactivity/async.js` and modified the compiler to generate `$.wait()` calls, but forgot to export it from `index.js`. This causes a runtime error when using `await` inside `$derived()` with `experimental.async: true`: ``` TypeError: $.wait is not a function ``` Fixes sveltejs#17529
PR sveltejs#17461 added the `wait` function to `reactivity/async.js` and modified the compiler to generate `$.wait()` calls, but forgot to export it from `index.js`. This causes a runtime error when using `await` inside `$derived()` with `experimental.async: true`: ``` TypeError: $.wait is not a function ``` Fixes sveltejs#17529
PR #17461 added the `wait` function to `reactivity/async.js` and modified the compiler to generate `$.wait()` calls, but forgot to export it from `index.js`. This causes a runtime error when using `await` inside `$derived()` with `experimental.async: true`: ``` TypeError: $.wait is not a function ``` Fixes #17529

fixes #17176
Thanks to @techniq's repro, I discovered that each child of an each block within an async boundary would go through the whole $.async() process, even if the parent promise had already settled.
Batch.decrement was also calling revive unconditionally, triggering thousands of flushes. I've added a guard around it so it's only called there when the batch is no longer deferred.
Last, not totally related to this, but a possible fix or improvement to #17342: I changed- extracted to separate prnew_depsto a Set, as I noticed the Array.includes on it was bogging down when tracking a high number of deps.Before submitting the PR, please make sure you do the following
feat:,fix:,chore:, ordocs:.packages/svelte/src, add a changeset (npx changeset).Tests and linting
pnpm testand lint the project withpnpm lint