fix(solid-query): consume hydration data client-side via a provider-owned streaming channel - #11168
Conversation
…hydration channel During SSR the adapter fetches queries and serializes each observer result, but since the Solid 2 rewrite dropped createResource's onHydrated hook nothing on the client ever primed the QueryClient cache: it came up cold after hydration and every observer refetched on mount, even for data well within staleTime. QueryClientProvider now owns a serialization channel: an async store whose generator emits cumulative snapshots of the dehydrated cache (query-core dehydrate() shapes) as queries settle during SSR. Solid serializes it through the normal per-computation path, so entries stream progressively and flush before the boundary content that awaited them. The channel closes itself on cache quiescence so the SSR stream can complete. On the client the provider applies each yield via query-core hydrate() (newer-wins) and useBaseQuery attaches each hydrated component's observer as soon as its query's entry is primed (or the channel completes), restoring normal mount semantics: fresh data does not refetch, stale data does, and earlier cache writes are reconciled at attach. The channel is store-shaped because Solid's hydration replay of signal-shaped async iterables collapses buffered yields into the latest result (dropping entries whenever hydration starts after their chunks arrived), while the store replay applies every yield in order. Yields are cumulative so collapsing intermediate states is lossless; entry objects keep their identity so seroval emits each entry once. The replay itself is detected without internals — a real Promise runs its executor synchronously, the hydration mock does not — leaving the adapter with zero sharedConfig or hydration-registry usage. The vestigial per-observer-result hydrationData copy is no longer serialized; nothing consumed it. Co-authored-by: Cursor <cursoragent@cursor.com>
Build a small fixture app with vite (string + streaming server bundles rendered in a node subprocess, hydratable client bundle) and hydrate it in jsdom with the real @solidjs/web hydrate() against the server HTML and serialized payload. The streaming fixture captures renderToStream chunks with timestamps and replays them in phases so a slow boundary holds the stream open while tests probe an already-hydrated section. Covers: channel payload in the SSR output (and no hydrationData field), warm cache within microtasks of hydration with the server's dataUpdatedAt (newer-wins), no mount refetch at staleTime 60s, mount refetch at staleTime 0, cache writes landing before the subscriber attach reconciled without a refetch, shell-flush entries primed at shell hydration rather than stream end (with the late entry verifiably absent until its boundary's flush), and hydrated components staying live — setQueryData and invalidateQueries both effective — while the stream is still open, with the late boundary hydrating correctly after. Co-authored-by: Cursor <cursoragent@cursor.com>
Hosts like TanStack Start prime the QueryClient through their own query-core hydrate() call before DOM hydration. Pin that the provider channel's re-priming of the same entries is silent (no cache updates, no observer churn, no refetch) and that its per-query attach coordination still resolves. Co-authored-by: Cursor <cursoragent@cursor.com>
…d replay conflation makes it the right container The store shape was a workaround for solid-js' signal-path hydration replay dropping buffered async-iterable yields (normalizeIterator let the stream's done result clobber the backlog, pinning the value at the first yield). With the conflate-to-latest replay fix (solid 23657d29, shipping in the beta after 2.0.0-beta.32), the natural shape works: the provider holds the channel as a plain async-iterable-valued createSignal(fn) computation, the server serializes the tapped iterator through the normal per-computation path, and the client replay conflates any buffered backlog to the latest yield — lossless exactly because yields are cumulative snapshots. Requires that beta: on stock beta.32, buffered replay leaves post-first-yield entries unprimed and their waiters unresolved (frozen components, verified empirically), so the solid pin must be bumped when the beta publishes. Wins over the store shape, verified on the fixture: no draft mutation or yield-undefined first-snapshot dodge (the JSON-cloned-first-snapshot quirk is store-path-only; the signal path serializes the first yield object directly, so entry identity and seroval reference dedup hold from the first yield — the terminal yield serializes as pure $R references), and 167 B / ~60 B gz smaller on the two-query fixture. Coordinator, whenQueryPrimed, and useBaseQuery semantics unchanged. Tests: new buffered-replay conflation integration test (entire stream delivered before hydrate() — all entries primed from the conflated snapshot, all observers attach, nothing refetches); string fixture now collects renderToStream via pipe() (renderToStringAsync is gone from current solid betas). 22 files / 329 tests green against a local solid build at the fix commit. Co-authored-by: Cursor <cursoragent@cursor.com>
- Bump solid-js 2.0.0-beta.29 -> 2.0.0-beta.33 plus matching @solidjs/web, @solidjs/signals, and babel-preset-solid bumps across the solid packages, solid-vite integration, and solid examples - Raise @tanstack/solid-query's solid-js peer range floor to 2.0.0-beta.33: the hydration channel added in this PR requires beta.33's normalizeIterator buffered-replay conflation fix (solid 23657d29). On <= beta.32, hydration that starts after more than one stream chunk has arrived pins the channel replay at its first buffered yield, so later queries are never primed and their observers never attach - silently frozen components, not graceful degradation. - Suite green against published beta.33: 22 files / 329 tests, vitest typecheck and eslint clean Co-authored-by: Cursor <cursoragent@cursor.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
View your CI Pipeline Execution ↗ for commit 70b2e10
☁️ Nx Cloud last updated this comment at |
…t headroom The first test executed by each type-checked RuleTester (parserOptions.project: true) pays the one-time cost of building the TS program for the ts-fixture. On a shared CI runner - this PR adds a solid-query test task that builds vite fixture bundles in parallel under Nx - that cold build pushed the first type-aware test in no-rest-destructuring.test.ts and no-void-query-fn.test.ts just past vitest's 5s default (5.9s measured), failing the run on timeouts with zero assertion failures. Raise the package's testTimeout to 15s; no test logic changes. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The two CI failures were collateral timeouts in Pushed a commit raising that package's |
…, unexport internal coordinator type The hydration fixture app is only reachable dynamically (built and rendered via a spawned build-and-render.mjs), so knip can't trace it; ignore the fixture dir in the solid-query workspace like the existing query-codemods/lit-query fixture ignores. HydrationCoordinator is only used within hydrationChannel.ts, so it doesn't need to be exported. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The remaining |
Target branch:
solid-query-v6-pre(@tanstack/solid-query@6.0.0-beta.x, Solid 2)Problem
During SSR the adapter fetches queries and serializes each observer result
through Solid's per-computation serialization, but on the 6.x line nothing on
the client ever primes the
QueryClientcache from it. The consumption usedto live in
createResource'sonHydratedcallback (it called query-core'shydrate()and attached the client subscriber), and it was lost when theSolid 2 rewrite (#10272 / #10316) replaced
createResourcewith apromise-returning derived memo, which has no
onHydratedhook.hydratableObserverResultstill attached a per-resulthydrationDatacopy —"this will be removed on client after hydration" — but no client code read it.
The result after SSR:
QueryClientcache comes up cold (getQueryState()returns pending /data: undefined) even though the data was fetched on the server andserialized to the client, and
well within
staleTime.The Solid team hit this while building a full external-SSR integration for
solid-query on Solid 2; the integration currently works around it with a
manual
dehydrate()/hydrate()pass in the template, which confirmed theserialized payload arrives intact — it just was never consumed.
Fix design
The dehydrated cache state now travels through a library-owned
serialization channel on
QueryClientProvider, the way@solidjs/router'squery()owns its serialization, instead of piggybacking a copy on everyobserver result and reading it back through per-owner registry lookups. The
adapter ends with zero
sharedConfig/ internal-API usage.Server: a signal-shaped async channel (
hydrationChannel.ts)QueryClientProviderholds the channel as a plain async-iterable-valuedcomputation — the computation's value IS the channel:
createServerDehydrationChannelsubscribes to theQueryCacheand, on everyupdated/successevent, emits a cumulative snapshot of the dehydratedcache — success entries in query-core
dehydrate()shape (dehydratedAt,state,queryKey,queryHash,meta?,queryType?), with entry objectsreused across snapshots while the query state is unchanged. Entries already
settled when the channel is created ride the first emission.
Because the channel is just a component's async signal value, Solid
serializes it through the normal path: the server runtime tees the iterator
into the hydration serializer (
processResult→ctx.serialize(id, tapped)in solid-js' server runtime), and seroval streams each cumulativeyield as a script chunk riding the SSR stream. Nothing reads the signal
during SSR, so it never suspends anything.
Termination. The SSR stream can only finish once every serialized stream
closes (
Serializer.flush()in seroval only firesonDonewhen its pendingcount drains, and the render root is disposed after that), so the channel
must close itself. It closes on cache quiescence: after every cache event
(and once at creation) it schedules a timer-task check; if no query is
fetching by then, no further settle can occur — suspense retry passes that
start waterfall fetches are scheduled on microtasks, so they have begun
before the check runs — and the channel emits a final cumulative snapshot
with
done: trueand completes. Measured on the streaming fixture theterminal yield and stream close ride the same flush cycle as the last
query's boundary.
Client: provider consumes, per-query attach coordination
During hydration the provider's signal replays from the serialized value
through Solid's ordinary public path (
hydrateSignalFromAsyncIterable/normalizeIteratorinternally — the adapter just owns a signal): yieldsthat were still buffered when hydration began are conflated to the latest
one, and live yields after that apply one at a time. A render effect reads
channelValue()and hands each yield to a hydration coordinator, which:hydrate()— inheriting its exactsemantics: newer-wins on
dataUpdatedAt,fetchStatuspreserved forin-flight fetches,
defaultOptions.hydrate(e.g.deserializeData)respected;
useBaseQuery(below), andreleases all remaining waiters when the channel reports
done(coversqueries that errored during SSR and were never dehydrated).
On a fresh client mount the compute returns
undefinedand none of thisruns.
useBaseQuery: replay detection + coordinated attachThe hydration replay runs the
queryResourcecompute withPromisemocked,so the promise executor that normally creates the client subscriber never
runs for hydrated queries (nor could it: a mount refetch started under the
mocked
Promisewould never settle). The replay is now detected with plainJavaScript instead of
sharedConfig: a realPromiseruns its executorsynchronously, the hydration mock does not, so an
executorRanflag isfalse exactly when the compute was replayed.
When that happens, the component asks the coordinator to attach its observer
once its query's entry has been primed (
whenQueryPrimed(queryHash, attach))— or on a plain microtask if there is no provider (manual
queryClientoption). Attaching applies query-core's normal mount semantics
(
shouldFetchOnMount/updateResult):staleTime→ no mount refetch,reactive,
The wait is per-query, not global-hydration-end: a component hydrated from an
early flush goes live while later boundaries are still streaming, so it is
not deaf to cache writes, is seen by
invalidateQueries' active-queryrefetch, and cannot be gc'ed while visible — a global "wait for hydration
end" design would reintroduce all three problems for early-flush queries.
hydratableObserverResultno longer attacheshydrationData— nothing elseconsumed it (TanStack Start's ssr-query bypasses it) — it now only strips
non-serializable functions from the resolved resource value.
Why the channel is signal-shaped and cumulative
The signal shape is the natural container — the channel is one value that
advances over time, not an object whose properties change independently —
and the cumulative yields are what make Solid's replay of it lossless:
begins after chunks have already arrived (the normal case for anything
but the shell, and the whole story for a slow client hydrating a
completed stream),
hydrateSignalFromAsyncIterablewraps thedeserialized iterator in
normalizeIterator, which drains thesynchronously-available backlog and delivers only its latest data
yield (the stream's done result follows on the next pull). Because
every yield is a cumulative snapshot, the latest one alone carries
everything the collapsed intermediates did: one
applyYieldcallprimes every entry and observes the
donemarker. Live (post-hydration) yields apply one at a time as their chunks execute.
reused across yields while the query state is unchanged, so seroval's
reference deduplication emits each entry once; later yields are arrays
of
$R[n]references (verified in the fixture payload: the terminalyield serializes as
{entries:[$R[14],$R[19]],done:!0}). Bytes scalewith the number of entries, not its square. Unlike the store path —
whose serialized first snapshot is JSON-cloned by the server runtime to
lock SSR-visible state, breaking identity for entries in it — the
signal path serializes the first yield object directly, so no
first-yield dodge is needed.
Minimum solid-js version. The conflate-to-latest replay ships in
solid-js 2.0.0-beta.33 (the
normalizeIteratorbuffered async-iterablereplay fix). On 2.0.0-beta.32 and earlier, the signal
replay pins at the first buffered yield: the batching loop lets the
stream's done result clobber the later data yields, so every entry after
the first yield — and the terminal
donemarker — is dropped wheneverhydration starts after more than one chunk has arrived. Verified
empirically on published beta.32 (2-query fixture, full payload delivered
before
hydrate()): the DOM keeps the server-rendered text for bothqueries and the first yield's query hydrates and attaches normally, but
the second query's cache entry is never primed and its
whenQueryPrimedwaiter never resolves — its observer never attaches, so the component
never refetches and is deaf to cache writes and
invalidateQueries. Thatis a silently-frozen component, not graceful degradation, which is why
this PR bumps the repo's solid pins and raises the package's solid-js
peer-range floor to 2.0.0-beta.33 (hydration that begins before the
stream's chunks arrive — the fast-client path — is unaffected even on
beta.32).
Design verification
Does serialization deliver progressively during streaming SSR? Yes.
Runtime path, server: solid-js's server
processResult(shared bycreateMemo/createSignal(fn)) detects the async-iterable computationvalue and serializes a tapped iterator via
ctx.serialize(owner.id, tapped);@solidjs/webrenderToStreamroutes that into seroval'sSerializer, which parses each yield as it arrives and pushes it throughsink.data()→ script chunk on the stream. Client: the deserializedseroval stream buffers pushed values and serves them to
hydrateSignalFromAsyncIterable, which conflates the buffered backlog toits latest yield and applies live yields as their chunks execute.
Ordering: entry vs. boundary. Measured on the streaming fixture
(chunk-level, timestamps in ms, signal shape):
headerentry$dfrevealfeedentrydone: true) +return$dfrevealThe entry patch flushes before its boundary's HTML in the same flush
cycle, because the cache settle event fires synchronously when the query
resolves, while the boundary's fragment flush waits for the boundary to
re-render and resolve. So an entry is always in the seroval buffer by the
time its boundary hydrates; the coordinator makes attach ordering
deterministic regardless.
Surface tally.
rg "sharedConfig|_\$HY|peekNextChildId|hydrationData"over
packages/solid-query/src(tests excluded): zero matches. Public APIsused for hydration: solid-js
createSignal(function form — the documentedasync computation protocol; async-iterable values are part of its public
compute contract),
createRenderEffect,createContext/useContext,runWithOwner; query-corehydrate,QueryCache.subscribe,QueryClient.isFetching, andQuery's publicstate/queryKey/queryHash/meta/queryType. Replay detection uses no API at all(observable
Promiseexecutor semantics).Byte cost (dev-mode fixture, two queries, string data, solid-js
2.0.0-beta.33 replay behavior): with the channel disabled →
signal-shaped channel, the full-settled string payload goes 3,085 → 5,780
bytes (1,151 → 1,972 gzipped) and the streaming payload 3,373 → 6,068
(1,205 → 2,035 gzipped). Roughly 1.7KB of the delta is seroval's one-time
stream/iterator replay helper code (fixed cost, amortized over any number
of queries, highly compressible boilerplate); the marginal per-query cost —
one dehydrated entry riding a cumulative yield of
$R[n]references — ison par with the removed per-query
hydrationDatacopy, anddatapayloadsof object shape deduplicate by reference against the serialized observer
results. For comparison, an earlier store-shaped prototype of the channel
measured 167 B (≈60 B gzipped) more than the signal shape on the same
fixture (patch wrappers and store scaffolding outweigh the
repeated-snapshot cost that reference dedup already eliminates).
Test coverage
packages/solid-query/src/__tests__/hydration.test.tsxadds realSSR → hydration integration tests (there was previously no SSR coverage in
the package). A small fixture app is built with vite (
fixtures/hydration/):string and streaming server bundles rendered in a node subprocess (so
solid-jsresolves to its server build) and a hydratable client bundle thatis hydrated in jsdom with the real
hydrate()from@solidjs/webagainstthe server HTML and its serialized payload. (The fully-settled string render
collects
renderToStreamthroughpipe()—renderToStringAsyncno longerexists on current solid-js 2 betas.) The streaming fixture captures
renderToStream's chunks with timestamps and replays them in phases, so aslow boundary can hold the stream open while tests probe an already-hydrated
section. Assertions:
(dehydrated entries), and no
hydrationDatafield anywhere,warm: server data and the server's
dataUpdatedAt(newer-wins semanticsobservable),
staleTime: 60_000never refetches on mount and the DOMkeeps the server-rendered value (client
queryFncall count stays 0),staleTime: 0refetches on mount per normal staleness rulesand the DOM updates to the client-fetched value (proving the
post-hydration subscriber works),
applied at attach time without a refetch,
the shell-flush query is primed with the server's
dataUpdatedAtand itsobserver attached (active query) within microtasks of shell hydration —
not at stream end — while the late query's entry is verifiably absent
until its boundary's flush arrives with it; this also proves the
mocked-
Promisehydration replay does not wedge the provider's channelconsumption,
data written to the cache reaches its DOM immediately and
invalidateQueriesrefetches it immediately, while the late-arrivingboundary still hydrates correctly afterwards,
hydrate()runs (slow-client case — every channel yield buffered), thereplay's conflate-to-latest still primes every entry with the server's
exact state, attaches every observer (the
donesnapshot is notdropped), refetches nothing, and leaves the components live. This is the
test that fails on solid-js ≤ 2.0.0-beta.32 (see the version note above).
Without the fix, the warm-cache and no-refetch assertions fail exactly as
described above (cold cache, mount refetch).
hydrate()call before DOM hydration (the TanStack Start pattern, seebelow) does not break the channel: double-priming produces zero cache
updatedevents for the pre-primed query, no refetch, and the attachcoordination still resolves.
Full package suite is green:
@tanstack/solid-query22 test files / 329tests passing, vitest typecheck clean, eslint clean — run against published
solid-js 2.0.0-beta.33.
Coexistence with TanStack Start
TanStack Start does not use this channel — its router integration
(
@tanstack/solid-router-ssr-query→router-ssr-query-core) primes theQueryClientitself via query-corehydrate(): an initial dehydrated blob(including pending queries as seroval-streamed live promises) plus a
queryStreamfor queries that start during rendering, all applied inrouter.options.hydratebefore Solid's DOMhydrate()runs. Since theintegration also wraps the app in
QueryClientProvider(
router.options.Wrap), under Start both channels are live. Measured onan instrumented Start app (
@tanstack/solid-start2.0.0-beta.30, solid-js2.0.0-beta.32, three queries exercising all three of Start's sub-channels):
provider inside Solid's normal hydration serialization context, so the
channel serializes and streams exactly as in standalone SSR; its entry
patches flush at each query's settle time, before that query's boundary
HTML, same as standalone.
(its hydrate hook runs pre-DOM-hydration); the provider channel then
re-primes the same entries with equal
dataUpdatedAt, which query-corehydrate()skips (it only writes strictly-newer data). The client cachetimeline shows exactly one
addedevent per query, zero extraupdatedevents, zero observer churn, zero spurious refetches (with
staleTimeset, client
queryFnexecutions stay 0 and all DOM tokens remain theserver values). The provider's attach coordination resolves normally —
each query's observer attaches at its boundary's hydration, and the page
is interactive mid-stream.
gzipped (+13.5%) on the three-query page, measured on the earlier
store-shaped channel; the final signal shape measured ~60 B gzipped
smaller than the store shape on the two-query fixture, so treat the
Start figure as a slight over-estimate. Roughly 650 B of it is fixed
seroval scaffolding; and because the channel's entries flush before the
per-owner observer results, the observer results reference the entry
objects instead of re-serializing their data (the observer-result scripts
shrank by 1,169 B in the same measurement), so the true duplicate cost is
under ~160 B gzipped per query.
under Start too. With default
staleTime: 0, hydrated queries nowrefetch on mount (as they always should have, and as React does after
HydrationBoundary); previously they silently never did because noobserver was ever attached after hydration. Apps that want no mount
refetch after SSR should set
staleTime, per standard TanStack SSRguidance.
Given benign-and-small, no stand-down mechanism is included; the redundancy
is one dehydrated copy per query. Start's integration could later delegate
to the provider channel (e.g. stop double-carrying settled entries) as a
follow-up on their side. The coexistence contract is pinned by the
external-
hydrate()test described above.Notes
2.0.0-beta.33 (the
normalizeIteratorbuffered async-iterable replayconflation fix, solid commit
23657d29). This PR includes a commitbumping the repo's solid pins from 2.0.0-beta.29 to 2.0.0-beta.33 and
raising
@tanstack/solid-query's solid-js peer-range floor to>=2.0.0-beta.33, because on ≤ beta.32 the failure mode under bufferedreplay is a never-primed, never-attached query (a silently frozen
component), documented with an empirical probe in the design section.
apps, shipping a solid-query release with this fix is gated on
TanStack/router#8026
(router-core currently quarantines render output flushed after
</body>instead of streaming it) landing and releasing first; thatgates the release coordination, not the review or merge of this
change.
patchfor@tanstack/solid-query) is included.ReactQueryStreamedHydrationaspires to be for React: per-flush cachepriming riding the framework's own serialization, with observers live
before the stream ends.
useQueriesdoes not go through this path (it did not serialize hydrationdata before either); with the channel in place its queries would be primed
too as soon as they settle on the server — client attach for it could be
covered in a follow-up.