Skip to content

fix: correct derived resolve order + don't process discarded batches#18177

Closed
dummdidumm wants to merge 3 commits into
mainfrom
batch-ordering-fixes
Closed

fix: correct derived resolve order + don't process discarded batches#18177
dummdidumm wants to merge 3 commits into
mainfrom
batch-ordering-fixes

Conversation

@dummdidumm

Copy link
Copy Markdown
Member

Fixes part of sveltejs/kit#15431

This contains two fixes which combined make one of the tests pass. While doing so I realized that #18167 wasn't quite correct (because the changes that are correct and made the test pass regressed others) so I adjusted that, too.

The first change is in deriveds.js#async-derived, where we don't just break when we come across our own batch, because that assumes that deferreds contains batches in incrementing order. But that's not the case.

Through that I discovered that #18167 wasn't quite correct because it basically undoes the decrement(skip) behavior and by accident got all the tests passing. The real reason #18167 was passing its new test is that the related batch was flushed to completion (because decrement(skip) ran the flush again, because skip was false). But it's flawed as seen by the new async-stale-derived-4 test. So I reverted part of #18167 and instead added logic to decrement to still run the batch if there is no more pending work left, or discard it if it's superseeded by the later batches.

The other change is to skip processing a batch if it's not in batches anymore. For that I tweaked where a new one is added to the Set, and skipping just-created-batches during batch.#commit()

Fixes part of sveltejs/kit#15431

This contains two fixes which combined make one of the tests pass. While doing so I realized that #18167 wasn't quite correct (because the changes that are correct and made the test pass regressed others) so I adjusted that, too.

The first change is in `deriveds.js#async-derived`, where we don't just `break` when we come across our own batch, because that assumes that `deferreds` contains batches in incrementing order. But that's not the case.

Through that I discovered that #18167 wasn't quite correct because it basically undoes the `decrement(skip)` behavior and by accident got all the tests passing. The real reason #18167 was passing its new test is that the related batch was flushed to completion (because `decrement(skip)` ran the flush again, because `skip` was `false`). But it's flawed as seen by the new `async-stale-derived-4` test. So I reverted part of #18167 and instead added logic to `decrement` to still run the batch if there is no more pending work left, or discard it if it's superseeded by the later batches.

The other change is to skip processing a batch if it's not in `batches` anymore. For that I tweaked where a new one is added to the Set, and skipping just-created-batches during `batch.#commit()`
@changeset-bot

changeset-bot Bot commented May 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2d97120

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

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Playground

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

@dummdidumm

Copy link
Copy Markdown
Member Author

No idea why tsgo suddenly fails, I suspect they had a release that either broke something or revealed type issues in our code. Either way unrelated to this PR

@Rich-Harris

Copy link
Copy Markdown
Member

The skipping logic isn't right, unfortunately. Here's a case that behaves correctly on main but wackily in this PR:

(If you add a pop button, or put fizz and buzz in the same template effect as n, then it's broken on main as well — I guess we have stumbled upon a new category of nightmare tests.)

Ideally I think skip would just be an optimisation, not something load-bearing. Struggling a bit to fully articulate what the behaviour should be when async deriveds are rejected as stale — i.e. what does it mean for one async derived to be rejected as stale in a batch with other changes? In the context of an explicit rejection from a getAbortSignal() abort handler, maybe the answer is 'we don't actually reject the promise, instead we eventually resolve it with a subsequent value' whereas if it comes from a destroyed effect then we do want to decrement; these are different things that our current model lumps together. Feels like we might be missing some vocabulary. Will think on it some more.

Meantime, I'll extract the uncontroversial bits of this PR

@dummdidumm

dummdidumm commented May 5, 2026

Copy link
Copy Markdown
Member Author

Sounds like we will need something like #17971 after all - essentially we gotta track which things of a batch are superseeded by others, but these newer ones don't outright reject the async value, instead they tell the batch "hey you're superseeded" and only once all are superseeded will it be discarded and free up the others to resolve. Until then it has time to resolve the values itself.

Meantime, I'll extract the uncontroversial bits of this PR

I'm having a hard time imagining how honestly 😓 the async-derived-stale-4 test needs the changed behavior that causes the main-regression, and the other one partly needs it aswell. And on main it may work visually but you get a zombie batch that is never going to get resolved, which can cause downstream problems. In fact, if you increment twice, shift twice, then increment twice again you'll see broken UI. Honestly I'd rather take the regression (as shown your example is broken anyway) and try to battle that in a follow-up (or on this PR).

Update: Found a way to get your example passing. We now tell the obsolete batch to resolve together with the latest subsequent batch that overlaps with it.

@Rich-Harris

Copy link
Copy Markdown
Member

The if (skip) logic feels a bit kludgy and hard-to-follow to me. I think we need to get rid of 'skipping' altogether — it's inherently confusing that a promise resolution/rejection will only sometimes cause the associated batch to flush. Instead, if we just leave stale promises hanging, they will naturally end up in an entangled state (i.e. an earlier batch will wait for its later counterpart, if the developer expressed that intent via getAbortSignal()) without us needing to force it. Opened #18180

dummdidumm added a commit that referenced this pull request May 7, 2026
…rejecting (#18180)

This incorporates some of the fixes and insights from #18177, but gets
rid of the `skip` logic.

Instead, we differentiate between _stale_ and _obsolete_ promises. A
promise is stale if it has been overtaken by a subsequent update, and
was rejected with `STALE_REACTION`:

```ts
async function search(query: string) {
  return fetch(`/search?q=${query}`, { signal: getAbortSignal() }).then((r) => r.json());
}
```

In this case, if we start typing `pot`, and then finish typing `potato`,
the first promise will eventually resolve with the results for
`/search?q=potato`, instead of the batch entering a weird limbo/zombie
state.

A promise is obsolete if it belongs to a now-destroyed effect, meaning
that toggling `show` doesn't result in an accumulation of
never-resolving batches:

```svelte
{#if show}
  {await neverResolves()}
{/if}
```

Fixes part of sveltejs/kit#15431

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
@dummdidumm

Copy link
Copy Markdown
Member Author

Closing in favor of #18180

@dummdidumm dummdidumm closed this May 7, 2026
@dummdidumm dummdidumm deleted the batch-ordering-fixes branch May 7, 2026 08:30
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