fix: don't re-suspend consumers awaiting a remote query when its value arrives - #16855
fix: don't re-suspend consumers awaiting a remote query when its value arrives#16855half2me wants to merge 2 commits into
Conversation
|
Install the latest version of pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/cfd8e42a0ea59b00030cb608b69d7ffb2233f14fOpen in Note This PR is from a fork. A maintainer must approve approve each commit before it can be built and installed. |
🦋 Changeset detectedLatest commit: cfd8e42 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 |
7ba64b6 to
fbd5f20
Compare
…awaited query Deriveds that are downstream of a remote query the page also `await`s stop being memoized: every read re-walks the whole dependency graph, so the cost is exponential in the depth of the graph. In a real app with a charting library it locks the main thread — millions of recomputations, no convergence, no recovery. The fixture reduces that to a dependency-free derived graph seven levels deep. Each level pairs a derived returning a fresh object (whose write version always increases) with one returning a constant (whose write version never does), which is the shape that makes the missing memoization visible. The page renders that graph twice over: once fed by a plain awaited promise, and once by an awaited remote query. The test counts recomputations rather than timing the interaction, so a regression fails in milliseconds with a number instead of hanging until the test times out, and it asserts the graph actually re-rendered so the control cannot pass while doing nothing. Currently the promise case costs 14 recomputations and the query case 27305. See sveltejs#16854 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTP5uWeBF5QDLADgw5UysS
`Query.set` always replaced `#promise` with a fresh resolved promise. That is what tells consumers awaiting the query that a new value is available, and it is needed for a server-initiated single-flight update, an explicit `.set(...)` and SSR hydration — but not when a request was already in flight. In that case `#clear_pending()` has just resolved the promise those consumers are suspended on, and `#then` reads `#current` after the promise settles rather than closing over the value, so they already see the new value. Replacing the promise as well invalidated `#then` a second time and re-suspended a consumer that was already awaiting the query, in a second batch, while the first was still pending. Two live batches put Svelte into 'time travelling' mode, where deriveds are deliberately never marked clean. Everything downstream of the query then recomputes on every read instead of once, at a cost exponential in the depth of the derived graph. A page rendering a chart from an awaited query locked up the main thread: millions of recomputations with no convergence and no recovery. Only replace the promise when consumers are not already suspended on one, or when the existing promise is a rejection that would otherwise keep being thrown. The existing `query-derived-memoization` test now passes: recomputations for one interaction drop from 27305 to 14, matching both the plain-promise control and @sveltejs/kit 2.64.0. Fixes sveltejs#16854 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTP5uWeBF5QDLADgw5UysS
fbd5f20 to
cfd8e42
Compare
|
Thanks! This is unfortunately incorrect. Superseded by #16958. |
|
Thanks @Nic-Polumeyv :) |
closes #16854
Deriveds downstream of a remote query that the page also
awaits stop being memoized, so every read re-walks the dependency graph. The cost is exponential in the depth of that graph — enough to lock the main thread on a page that renders a chart, with no error, no failed boundary and no recovery short of a reload.Cause
Query.set()unconditionally replaced#promisewith a fresh resolved promise. That identity change is how a consumer awaiting the query learns a new value is available, and it is needed for a server-initiated single-flight update, an explicit.set(...)and SSR hydration — but not when a request was already in flight.In that case
#clear_pending()has just resolved the promise those consumers are suspended on, and#thenreads#currentafter the promise settles rather than closing over the value, so they already see the new value. Replacing the promise as well invalidated#thena second time and re-suspended an already-awaiting consumer, in a second batch, while the first was still pending.Two live batches put Svelte into 'time travelling' mode, where
update_derivedandis_dirtydeliberately never mark a derivedCLEAN. Everything downstream of the query then recomputes on every read instead of once.This is a 3.x regression rather than a Svelte one because the response path moved. In 2.64.0 the fetch callback returned the value and
#run()'s own.thenwrote it, soset()was never involved in a response. In 3.x the callback returns nothing andremote_requestinjects the value throughset(), putting it on the hot path for every query response.Fix
Replace the promise only when consumers are not already suspended on one, or when the existing promise is a rejection that would otherwise keep being thrown.
Test
test/apps/async/src/routes/remote/query-derived-memoizationrenders the same seven-level derived graph twice: once fed by a plain awaited promise (the control) and once by an awaited remote query. The graph alternates nodes returning a fresh object with nodes returning a constant, which is the shape that makes lost memoization expensive.The test counts recomputations rather than timing the interaction, so a regression fails in milliseconds with a number instead of hanging until the test times out, and it asserts the graph actually re-rendered so the control cannot pass while doing nothing.
Measured over one interaction, with
svelte@5.56.8andvite@8.2.1held constant so the Kit version is the only variable:@sveltejs/kit2.64.0The two commits are separated so the test can be checked out on its own and observed to fail.
Verified separately against the original
layerchartreproduction from #16854:FROZENon the first press before,25ms/11bars / 25ms/12bars / 18ms/13bars / 27ms/14barsafter.Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
Tests
pnpm testand lint the project withpnpm lintandpnpm checkpnpm lint,pnpm checkandpnpm -F @sveltejs/kit test:unit(737 passed) are clean. The fulltest/apps/asyncsuite passes in both dev and build modes apart from fourquery.livetests that fail identically on an unmodified checkout ofmain.Changesets
pnpm changesetand following the prompts. Changesets that add features should beminorand those that fix bugs should bepatch. Please prefix changeset messages withfeat:,fix:, orchore:.Edits
🤖 Generated with Claude Code