fix(query): don't cache never-settling promises from hydration tracking runs - #644
Merged
Merged
Conversation
…ng runs A hydrating client re-runs each serialized compute once as a tracking run, with the global Promise and fetch swapped for stubs that never settle (solid's subFetch). The first query() read of a key usually lands there, and two paths cached one of those stubs: - Adoption (since #641) wrapped an already-settled flight entry with the global Promise.resolve/reject, so the cache held a stub. Navigations that reused it inside the preload window hung with isRouting() stuck. Adopted entries are now wrapped with Promise captured at module load. - f513771 stamps adopted entries at boot and lets reads with no navigation in flight adopt at any age, but the cache-hit check never got that rule. A boundary resuming more than PRELOAD_TIMEOUT after boot, after the first reader was disposed (count 0), saw the entry as stale, found the one-shot payload already taken, and ran fn() inside the tracking run. A read with no navigation in flight while hydrating (isHydrating()) now reuses the cached entry at any age. Navigations keep normal freshness. Tests: unit cases for both rules, plus an SSR-stream + hydration JSDOM harness (adapted from #636 by @everton-dgn) covering the round trip after adoption in a tracking run, a boundary resuming 6s after boot for fetch-chain and async query functions (no fn call, no requests), a mid-stream click past the window, and isHydrating() inside the late boundary's tracking run. Fixes solidjs/solid#3721. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 99ed6cf 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 |
ryansolid
added a commit
that referenced
this pull request
Sep 30, 2026
…module loads (#625) With file-route style lazy routes (a moduleUrl the server manifest answers for), the root <Loading> serializes a module map and its hydration waits for the client preload, while the router outside the boundary already takes clicks. Before #641 the router's location write forced sharedConfig.done, so the boundary resumed live against the new route: the fallback missed its hydration key and the server-rendered page stayed unclaimed. On #641 without #644 the destination rendered but the navigation back hung. The new fixture renders with a manifest and stands in for the browser's import() by seeding _$HY.loading behind a gate. The harness gains two generic hooks: an optional app.manifest for renderToStream and an optional app.preload(_$HY) before hydrate(). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes solidjs/solid#3721
Root cause
Solid 2.0 hydration re-runs each serialized compute once as a tracking run, which records dependencies and then discards the result. During that run, core swaps the global
Promiseandfetchfor stubs that never settle (subFetch). The firstquery()read of a key usually happens inside that run.query.tshad two paths that stored one of those stubs in the cache.1. Adoption wrapped settled entries with the stubbed
Promise(regression from #641)When a flight entry from
takeHydrationValue(key)had already settled, #641 wrapped it with the globalPromise.resolve(entry.value)/Promise.reject(entry.error). Inside a tracking run that global is the stub, so the adopted cache entry never settled. Any navigation that reused it inside the preload window awaited it forever:isRouting()stayedtrueand later navigations were dropped.Fix: capture
const NativePromise = Promiseat module load and use it to wrap adopted entries. The stub swap can't reach a module-local binding.2. The cache-hit check was missing the hydration rule from
f513771f513771(next.15) stamps adopted entries atbootTime, their true age, and states the policy that outside a navigation (no intent: the hydration/claim case), server-DOM consistency wins at any age. Only the adopting read applied that rule. The cache-hit condition (isServer || intent === "native" || count || age < PRELOAD_TIMEOUT) did not.So a second hydration read of the same key could fail. This happens when a streamed
Loadingboundary, or a lazy route's boundary, resumes more than 5 s after boot, after the first reader's tracking-run owner was disposed (count 0). That read saw the entry as expired and found the one-shot payload already taken. It then calledfn()inside the tracking run. With a fetch chain or anyasyncfunction, such as a server function's client stub, that caches a promise that never settles.Fix: add
(!intent && isHydrating())to the cache-hit condition, using the publicisHydrating()fromsolid-js. A read with no navigation in flight while hydrating now reuses the cached entry at any age, the same rule adoption already applies. Navigations during hydration keep normal freshness. The comment block now describes one policy for both paths.Tests
Run against the published
solid-js/@solidjs/web2.0.0-rc.13the router depends on.Unit tests (
test/data/query-flight-consumption.spec.ts, withisHydratingmocked the same way the intent already is):Promisethat never settles still resolves for a later navigation (Case 1).fn(Case 2).PRELOAD_TIMEOUT.SSR stream + JSDOM hydration harness (
test/server/hydration-navigation.spec.ts, adapted from #636 by @everton-dgn, now building two fixtures):isHydrating()isfalseafter the initial pass andtrueinside the late boundary's tracking run. This is what makes the Case 2 rule fire for a boundary that resumes late.fncall and 0 requests, for both a fetch-chain and anasyncquery function. Later navigations complete.fncall and 0 requests.Without the Case 2 line, the two 6 s variants and the mid-stream-click case fail. Without the
NativePromisecapture, six of the seven harness cases and the Case 1 unit test fail.Results: the
testscript passes (469 jsdom tests, 75 server tests, and the test typecheck). Thebuildscript passes, including the fs gate.Public API Changes
None.
Behavior change: during hydration, a
query()read with no navigation in flight now reuses the cached entry regardless of its age, instead of refetching once it's older thanPRELOAD_TIMEOUT. Navigations, preloads, back/forward,revalidate(), and reads after hydration keep their existing freshness rules.