fix: defer batch resolution until earlier intersecting batches have committed
#17162
Closing issue
Describe the bug
When I have pending async work happening (e.g. mounting an async component) I am getting strange and erroneous results while modifying state elsewhere.
See the reproduction for examples.
Reproduction
Reproduction: https://svelte.dev/playground/c0f672d625bb47f28808234647d8a5f3?version=5.42.2
This example contains a list of incrementing numbers which can be added to by clicking the button. Each list item will also cause an async component to be rendered.
If you rapidly click the "Add to list" button you will be appending to the list while async work in happening from mounting new the components.
When rapidly clicking you will observe
- The original list will temporarily have gaps in it
- The derived list showing the type of each element will have
undefinedwhere the gaps are
- The printed results should eventually reconcile
- Printing the lists as a text expression
{list}gives different results than printing inside of an#{each}block until the results reconcile
The primary issue here is (1) (and by extension (2)) where we temporarily have incorrect records. In the real world this leads to unexpected errors such as trying to access properties of undefined.
Logs
System Info
System:
OS: Linux 6.15 Arch Linux
CPU: (12) x64 AMD Ryzen 5 2600 Six-Core Processor
Memory: 4.59 GB / 15.54 GB
Container: Yes
Shell: 5.3.3 - /bin/bash
Binaries:
Node: 22.17.0 - /home/yung/.nvm/versions/node/v22.17.0/bin/node
Yarn: 1.22.22 - /usr/bin/yarn
npm: 10.9.2 - /home/yung/.nvm/versions/node/v22.17.0/bin/npm
pnpm: 10.13.1 - /home/yung/.local/share/pnpm/pnpm
Browsers:
Chromium: 139.0.7258.66
Firefox: 141.0
Firefox Developer Edition: 141.0
npmPackages:
svelte: ^5.41.4 => 5.41.4
Severity
blocking an upgrade
Pull request
This fixes an awkward bug with each blocks containing await, especially keyed each blocks.
If you do array.push(...) multiple times in distinct batches, something weird happens — to the each block, the array looks this...
[1]
...then this...
[undefined, 2]
...then this...
[undefined, undefined, 3]
...and so on. That's because as far as Svelte's reactivity is concerned, what we're really doing is assigning to array[0] then array[1] then array[2]. Those (along with array.length) are each backed by independent sources, which we can rewind individually when we need to 'apply' a batch. When it comes to sources that actually are independent, this is useful, since we apply the changes from batch B to the DOM while we're still waiting for a promise in batch A to resolve. But in this case it's not ideal, because you would expect these changes to accumulate.
In particular, this fails with keyed each blocks because duplicate keys are disallowed (assuming the key function doesn't break on undefined before the duplicate check happens).
This PR fixes it by stacking batches that are interconnected. Specifically, if a later batch has some (but not all) sources in common with an earlier batch, then when we apply the batch we include the sources from the earlier batch, and block it until the earlier batch commits. When the earlier batch commits, it will check to see if doing so unblocks any later batches, and if so process them.
In the course of working on this I realised that SvelteSet and SvelteMap aren't async-ready — will follow up this PR with ones for those.
Fixes #17050
Info
🦋 Changeset detected
Latest commit: cff364e
The changes in this PR will be included in the next version bump.
This PR includes changesets to release 1 package
| Name | Type |
|---|---|
| svelte | Patch |
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
pnpm add https://pkg.pr.new/svelte@17162I like that this now makes overlapping changes line up better, . Will dig some more for things where blocking would make sense.
In a sense this is a light version of #17872.
I do wonder if this makes the part where we rerun async effects in #commit obsolete, since they will now be blocked.
Seems the merge made one test fail with the changes in this PR - question is whether it's expected since things are blocking now. When removing the rebase stuff as outlined in my previous comment then only async-linear-order-same-derived fails in addition. Haven't looked into whether that's a blocker of if we can tweak logic elsewhere to make it work properly
Gah, yeah, it's because of this line added in #17917:
I was nervous about that line, mostly because I was worried about unanticipated scenarios in which a derived became stale, but couldn't come up with a test that failed because of it so I merged it anyway... shame we didn't merge the two PRs in the opposite order 😆
Essentially, the thinking behind this logic...
...was that batch B is blocked on batch A if they contain sources in common and batch A contains one or more sources that batch B does not contain. (It's possible that the latter condition should apply in both directions, i.e. batch B containing sources batch A doesn't have, not 100% sure.)
Adding deriveds to batch.current alongside sources breaks that logic, because even if batches A and B contain the exact same sources, they may contain different deriveds simply by virtue of timing (i.e. a derived was evaluated in batch A but wasn't evaluated yet in batch B, even though it will be).
We could add this inside the loop...
if ((source.f & DERIVED) !== 0) continue;
...but that excludes deriveds that were written to, which for these purposes should be treated as sources.
Not entirely sure how to proceed. Maybe there's an alternative way to implement #17917 that doesn't involve writing deriveds to batch.current?
where the blocking behavior results in undesired behavior. Click one button then the other and you'll see that the second click is blocked on the first. But we should be able to see that by toggling show again we make the async inside obsolete and not block on it, maybe by associating async work with branches and see if that branch is still alive in the later block, and if not ignore it. But I'm not sure how we would be able to model that via current/previous, it seems we have to model it via overlapping async effects instead. But this could undo the fix here. Mhm.
Perhaps the way to think about is that in #process, instead of this...
if (this.#is_deferred() || this.#blockers.size > 0) {
...it's this...
if (this.#is_deferred() || this.#is_blocked()) {
...and this.#is_blocked only returns true if one of this.#blockers has a pending async effect that isn't skipped. In other words instead of #blocking_pending being a number, it's a Set<Effect>
That example now works as you'd expect. Added a test for it
This also - worth adding this as a test
Merge branch 'async-each-preserve-pending' into each-block-pending
• Feb 27, 2026, 1:57 AMMerge branch 'async-each-preserve-pending' into each-block-pending
• Feb 27, 2026, 11:03 PMPro tip: You can prefix GitHub URLs of issues, PRs or discussions with svcl.dev/ to view them on this page! Also try it on a GitHub release ;)