Skip to content

fix(query): don't cache never-settling promises from hydration tracking runs - #644

Merged
ryansolid merged 1 commit into
nextfrom
fix/query-hydration-3721
Sep 30, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/query-hydration-3721

Conversation

@ryansolid

Copy link
Copy Markdown
Member

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 Promise and fetch for stubs that never settle (subFetch). The first query() read of a key usually happens inside that run. query.ts had 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 global Promise.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() stayed true and later navigations were dropped.

Fix: capture const NativePromise = Promise at 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 f513771

f513771 (next.15) stamps adopted entries at bootTime, 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 Loading boundary, 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 called fn() inside the tracking run. With a fetch chain or any async function, 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 public isHydrating() from solid-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/web 2.0.0-rc.13 the router depends on.

Unit tests (test/data/query-flight-consumption.spec.ts, with isHydrating mocked the same way the intent already is):

  • A settled entry adopted under a Promise that never settles still resolves for a later navigation (Case 1).
  • A late hydration read more than 5 s after boot, with the payload already taken and count 0, serves the cached entry without calling fn (Case 2).
  • Outside hydration, a stale read with no intent still refetches (the scope of the new rule).
  • A navigation during hydration still refetches an adopted entry past PRELOAD_TIMEOUT.

SSR stream + JSDOM hydration harness (test/server/hydration-navigation.spec.ts, adapted from #636 by @everton-dgn, now building two fixtures):

  • After the stream ends, and on a link click mid-stream, the app can navigate away and back to the hydrated route (Case 1).
  • isHydrating() is false after the initial pass and true inside the late boundary's tracking run. This is what makes the Case 2 rule fire for a boundary that resumes late.
  • A navigation inside the short window reuses the entry adopted in the root preload's tracking run with 0 requests.
  • A boundary resuming 6 s after boot reuses the adopted entry with no fn call and 0 requests, for both a fetch-chain and an async query function. Later navigations complete.
  • A link click mid-stream 6 s after boot lands once the boundary resumes, with no fn call and 0 requests.

Without the Case 2 line, the two 6 s variants and the mid-stream-click case fail. Without the NativePromise capture, six of the seven harness cases and the Case 1 unit test fail.

Results: the test script passes (469 jsdom tests, 75 server tests, and the test typecheck). The build script 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 than PRELOAD_TIMEOUT. Navigations, preloads, back/forward, revalidate(), and reads after hydration keep their existing freshness rules.

…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-bot

changeset-bot Bot commented Sep 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 99ed6cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/router 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

@ryansolid
ryansolid merged commit d8288bd into next Sep 30, 2026
2 checks passed
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>
@ryansolid
ryansolid deleted the fix/query-hydration-3721 branch September 30, 2026 21:36
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.

1 participant