Skip to content

fix: use right batch/branch on first run#18162

Closed
dummdidumm wants to merge 2 commits into
mainfrom
batch-branches-fix
Closed

fix: use right batch/branch on first run#18162
dummdidumm wants to merge 2 commits into
mainfrom
batch-branches-fix

Conversation

@dummdidumm

Copy link
Copy Markdown
Member

Fixes the regression that was uncovered by a SvelteKit test. It consistens of two parts:

  1. Restoring the latest, not the initial batch in flatten: At the beginning flatten stores the current batch, and once everything async is finished it restores it. That falls down if one of the async deriveds reruns during that time. Now the batch associated with that rerun should be used because its current map contains a later value. We gotta use that. The hacky fix for now is to set the latest batch onto the source that the async derived has. That could mess with perf since the source object shape is not the same all the time anymore. There's probably a better way to write this but it's getting late here. This fix solves the regression part where it shows the wrong string for the url pathname.
  2. branches deletes older batches and then bails when it comes across itself. The logic assumes that batches are in the map in ascending order but that's not always true. Making the logic robust to that fixes the part where it keeps showing the "should never see this" string from the obsolete +page.svelte. I wasn't able to make a test for this yet.

Fixes the regression that was uncovered by a SvelteKit test. It consistens of two parts:

1. Restoring the latest, not the initial batch in `flatten`: At the beginning `flatten` stores the current batch, and once everything async is finished it restores it. That falls down if one of the async deriveds reruns during that time. Now the batch associated with that rerun should be used because its `current` map contains a later value. We gotta use that. The hacky fix for now is to set the latest batch onto the source that the async derived has. That could mess with perf since the source object shape is not the same all the time anymore. There's probably a better way to write this but it's getting late here. This fix solves the regression part where it shows the wrong string for the url pathname.
2. branches deletes older batches and then bails when it comes across itself. The logic assumes that batches are in the map in ascending order but that's not always true. Making the logic robust to that fixes the part where it keeps showing the "should never see this" string from the obsolete `+page.svelte`. I wasn't able to make a test for this yet.
@changeset-bot

changeset-bot Bot commented Apr 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 184b86f

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

@svelte-docs-bot

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Playground

pnpm add https://pkg.pr.new/svelte@18162

Rich-Harris added a commit that referenced this pull request May 1, 2026
While looking into #18162 I found an adjacent bug. Currently, if an
async derived resolves in batch 2 before it resolves in batch 1, we
reject the promise belonging to batch 1 and by extension the batch
itself. This means that any other changes in batch 1 are silently
discarded, incorrectly.

The fix is almost comically simple: rather than rejecting the earlier
promise, we just resolve it with the latest value.

I have a hunch that this might also enable us to simplify the rebase
logic, though I haven't investigated that in this PR.

### Before submitting the PR, please make sure you do the following

- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).

### Tests and linting

- [x] Run the tests with `pnpm test` and lint the project with `pnpm
lint`
@Rich-Harris

Copy link
Copy Markdown
Member

Still trying to wrap my head around the right way to think about this problem. But the more I think about it the more I think get_latest_async_batch isn't the right logic, and this example shows why:

When we use the most recent batch for the template effect, instead of the batch in which it was created, we see tearing (the first occurrence of {b} updates after the the second).

So this approach gets rid of one form of tearing (between {await push(count)} and {count} but in the process introduces another.


Thinking aloud time:

This is a lightly-tweaked version of the test in this PR, minus the boundary (which I don't think is relevant to the problem we're trying to solve).

If we press 'increment' twice then 'pop', the sequence of events is this:

  1. batch 1 is created on mount and immediately resolves
  2. batch 2 is created with count++ and other++, and does not resolve because of await push(count)
  3. batch 3 is created with count++, and is blocked on batch 2 because it overlaps (with count) but is not a superset (because of other)
  4. the await push(count) promise created in batch 3 resolves with 2, the current value of count
  5. batch 3 is processed, but is blocked on batch 2
  6. the await push(count) promise belonging to batch 2 also resolves, also with 2, because of the d.resolve(value) in deriveds.js
  7. as a result of that promise resolving, the template effect is created (in the context of batch 2, which I think is correct), and {await push(count)} {count} {other} is rendered as 2 1 1 (which is clearly incorrect)
  8. batch 2 is processed, and it resolves which means the newly created template effect is appended to the DOM
  9. during batch.#commit, we rebase the resolved batch onto subsequent batches, by identifying any async deriveds that need to re-run. For reasons I don't fully understand, await push(count) is included in that set (I would expect it to be equal between batches 2 and 3). We therefore run that async effect again, which causes batch 3 to become pending once more
  10. after rebasing, we see if there are any batches that are now unblocked. had we not just triggered a re-run of the async effect, that would include batch 3, but instead it remains pending while the torn result of batch 2 is rendered

In this case it would be sufficient to fix 9 such that the async effect doesn't re-run. But I suspect that would still result in buggy behaviour in other cases. Ultimately I think we need to fix 7. This PR does so by creating the template effect in the context of batch 3, but as explained above I don't think that's quite right. Maybe this is a case where batch 2 needs to wait until batch 3 is ready (similar to #17872). Or maybe we need to rethink blocking: instead of only resolving batch 3 once its blockers have completed, we apply batch 3's values onto its blockers and then discard it?

The journey continues.

@dummdidumm

Copy link
Copy Markdown
Member Author

Yes blocking needs more thought. I'm not sure if it's a series of tweaks or if we need to rethink how blocking works in general. E.g. another thing I found:
Batch C could be a superset of batch B, and then when it resolves just apply itself directly. But that could be wrong if batch B is blocked by batch A, which could also block C. i.e. we need some kind of "inherit blockers" logic.

I'm also not sure if the current blocking mechanism finds all types of correct blockers, or if some could be false positives. e.g. what if batch B will destroy the sources of batch A that are different to it (and therefore B thinks it needs to wait for A but it doesn't need to).

... which all makes me wonder if finding blockers through the reactivity graph (similar to how I try to entangle in #17872) could be another avenue.

@Rich-Harris

Copy link
Copy Markdown
Member

Note to self: an example of the situation above could be

  • a: {foo, bar}
  • b: {foo, baz}
  • c: {foo, baz, qux}

@Rich-Harris

Copy link
Copy Markdown
Member

(Actually is that right? c would also be blocked by a in that example.)

@dummdidumm

dummdidumm commented May 5, 2026

Copy link
Copy Markdown
Member Author

yes I noticed that, too, and now I'm not sure why it would happen. But it definitely helps fix the bug described in sveltejs/kit#15431. I'll have to investigate more.

@dummdidumm

Copy link
Copy Markdown
Member Author

With the changes in main, in your "lightly-tweaked version"-playground, the batches now correctly resolve in order. The UI shows 1 1 1 as the end result though. Not sure why this happens for {count} yet but for {await push(count)} it's because batch 3 resolves the async before batch 2, and so the later values is set on the signal earlier than the earlier value. As a result count.v is 1.

Rich-Harris added a commit that referenced this pull request May 13, 2026
This is another attempt to address some of the tricky edge cases that
arise when async batches resolve out of order. The idea is this:

Essentially, when a batch resolves:

1. we find the latest batch it shares changes with
2. if none exists (either because this is the earliest batch, or because
it is independent of any earlier batches):
    - we commit it: effects are flushed, `oncommit` callbacks are run
- we restart any async work in later batches that depends on both the
committed values and the later batch's changes
3. otherwise, we don't commit the batch. instead:
- we merge the changes from the later batch onto the earlier batch. in
some cases this may mean restarting async work on the earlier batch, but
we avoid doing so unnecessarily.
    - we then `#process()` the earlier batch. if it resolves, goto 1

This feels like it ought to work. There are still two failing tests,
which I'm currently looking into.

Notable changes:

- Instead of having a `batches` set, we have a linked list. When a batch
resolves, this makes it easy to find the batch that it should be merged
into
- The `#is_deferred` logic now takes account of skipped effects —
there's no need to wait for a promise inside a falsy `if` block (this
accounts for the change in the `async-inner-after-outer` test)
- We no longer care about `#blockers`

I would like to believe that this approach will allow us to simplify and
delete some code, for example the `rebase` logic, though that remains to
be seen. It also feels like some version of #18035 would be helpful.

- closes #18189
- closes #18162
- fixes sveltejs/kit#15431

### Before submitting the PR, please make sure you do the following

- [x] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).

### Tests and linting

- [x] Run the tests with `pnpm test` and lint the project with `pnpm
lint`

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants