feat(solid-query): rewrite the adapter onto Solid 2.0's native async model - #11308
feat(solid-query): rewrite the adapter onto Solid 2.0's native async model#11308ryansolid wants to merge 13 commits into
Conversation
Reads become a single async memo over the query cache (suspends into <Loading>, holds previous data through refetches, routes rejections to <Errored>, serializes settled values for streaming hydration); mutations ride Solid core's `action` primitive with transactional settle and optimistic overlays; cross-cache hooks share a dual-write aggregate with a hydration latch. The observer notification/store layer (createStore/reconcile, tracking proxies, notifyManager batching) is deleted — QueryObserver/MutationObserver remain as lifecycle/policy engines with noop listeners. Breaking: result `data` is non-optional (reads suspend instead of returning undefined), mutateAsync is removed (mutate returns a promise), and the reconcile/suspense/deferStream options are removed. SSR contracts pinned by the fixture suite: settled-only serialization for every exposed field, hydration id parity, no fetches inside the hydration/restore windows, and hydration-latched cross-cache aggregates. Validated against solid-js 2.0.0-rc.0 and rc.3, including an end-to-end template run with single-flight mutations. 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 |
|
View your CI Pipeline Execution ↗ for commit 94480d6
☁️ Nx Cloud last updated this comment at |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Looks like some tests failed. The main query one passed though. Just some surrounding ones |
…n SSR option deferStream was declared in the option types on this line but wired to nothing. Solid 2 exposes it as a creation-time memo option, so pass it straight through to the data node: the server holds the stream flush until the query resolves instead of flushing the surrounding boundary's fallback. Server-only; ignored on the client. Co-authored-by: Cursor <cursoragent@cursor.com>
|
I tested this in the fullstack-tanstack template and seems to function better |
…ction Solid 2 projections auto-reconcile their returns by key, so the data face becomes a store: deep reads are fine-grained and item identity survives across refetches (keyed by the new `reconcile` option, default 'id'). The async memo remains the SSR/hydration spine underneath; the projection derive is inert on the server — server projections run eagerly at creation, so a live derive would fetch at hook creation and serialize a duplicate payload, and the serialized entry would latch the hydrating client's projection for the whole stream. With nothing serialized the client projection computes live through the hydration window. Rewrites the structural-sharing test to the stronger store contract and adds leaf-granularity, custom-key, and primitive-data semantics tests. Co-authored-by: Cursor <cursoragent@cursor.com>
- align @solidjs/web, babel-preset-solid and solid-js dev ranges across the solid packages (sherif flagged the split, and devtools/persist-client were resolving a second solid-js copy) - drop the unused `export` on the internal BaseQueryLayer seam (knip) - port the PersistQueryClientProvider suite to the 2.0 read layer: a query with nothing cached now suspends for the whole restore window, so the tests assert the visible fallback -> restored -> refreshed transitions and the fetch/callback bookkeeping instead of observer result snapshots Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ction Collapse the three-node data face (serialized async memo + transparent hydration memo + client-only projection) into one createProjection with an identical derive on both sides — the Solid Router feed-query-into- projection shape. The engine owns SSR serialization (deferStream rides the projection now), suspense, hydration adoption, and keyed reconciliation. ssrSource 'hybrid' keeps hydrated components live: the serialized value claims the DOM and the derive re-takes over from the live cache per hydrated region. A per-hook `primed` signal gates the pull-model fetch so the hydration takeover recompute cannot race data the channel is about to deliver (SSR-errored queries fall through and fetch normally once the channel declines them). Co-authored-by: Cursor <cursoragent@cursor.com>
… data node Drop ssrSource 'hybrid' from the data projection: query data IS server-serialized truth, so the serialized value owns the DOM for the whole hydration window. Mid-stream cache writes commit when the stream closes through Solid's hydration-end divergence takeover (solid-js > 2.0.0-rc.3 — earlier engines lose a latched node's mid-stream divergence instead of deferring it). Network activity is unchanged: observers attach per-query as the channel primes, so mid-stream invalidations still refetch immediately; only the DOM commit defers to hydration end. Co-authored-by: Cursor <cursoragent@cursor.com>
… the transport
Delete the provider-owned dehydration channel and its coordinator. The
data projection's serialized root now carries the cache facts hydration
needs ({ value, t: dataUpdatedAt, raw?: pre-select data }); on a hydrated
mount each hook peeks its projection's node id, loads that entry itself,
primes the query cache through query-core hydrate() (staleness intact,
newer-wins, silent under external pre-priming), and attaches its observer
per query as chunks land. One serialization story, engine-owned; ~240
lines deleted. Requires solid-js > 2.0.0-rc.3 (peekNextChildId export).
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…uery() pattern)
Serialize cache entries under sq:<queryHash> at fetch-dispatch time
(provider-side, promise-valued so streaming settles ride the payload)
instead of tying transfer to a rendered node's positional id. Client
hooks look their own hash up and prime through query-core hydrate();
the registry outlives the hydration window, so prefetched-never-rendered
queries transfer and late mounts (lazy routes, post-hydration navigation)
adopt the server payload instead of refetching — restoring full
cache-level dehydration coverage with less machinery. Priming moved ahead
of the attach render effect (its effect half runs synchronously inside an
active flush, and mount policy against a cold cache would refetch).
Drops the peekNextChildId dependency; the data root reverts to { value }.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ck#8950 deprecation The create*/Create* names were kept as a migration bridge when v5 made use* canonical (TanStack#8950 deprecated them). This major already rewrites every callsite's semantics, so the bridge retires: use* is the only naming, matching the docs. Co-authored-by: Cursor <cursoragent@cursor.com>
Target branch:
solid-query-v6-pre(@tanstack/solid-query@6.x, Solid 2)What this is
A ground-up rewrite of the Solid Query adapter onto Solid 2.0's native
async model. The v6-pre adapter still mirrors the React adapter's
architecture — observer notifications driving a store, tracking proxies,
manually-thrown
NotReadyErrors to mimic suspension — which fights theframework: Solid 2 components don't re-render, its reactive graph already
knows how to suspend on a pending promise, hold previous values through
refetches, route rejections to error boundaries, and serialize settled
async values for streaming hydration. The rewrite hands those jobs to the
framework and keeps TanStack Query for what the framework does not do:
the cache, key identity, staleness/GC policy, retry/dedupe/cancellation,
devtools, and the ecosystem contract.
The result deletes the notification/store layer entirely:
createStore/reconcile, tracking proxies,notifyManagerbatching, andobserver-notification coupling are gone, replaced by
memo/projection/signal/action/optimistic primitives.
QueryObserverandMutationObserversurvive only as lifecycle/policy engines (GC pinning,mount-fetch policy, refetch intervals, focus/reconnect) with noop
listeners.
Architecture
Reads (
useBaseQuery)One memo is the data node: it returns either a settled value or the live
fetch promise, and the engine does the rest — first loads suspend into
<Loading>, refetches hold the previous committed value, rejectionssurface to
<Errored>, and on the server the settled value serializesinto the hydration payload.
for the query's hash (committed OR latest-computed hash — they diverge
during a held transition, and settle events for a new key land before
the hold commits). Every derived read pulls fresh state from the cache
through it.
read itself (the router
query()model). This is what revives querieswhose enabling flips while parked under a suspended boundary, where
effects cannot run.
q.fetchdedupes against policy-driven fetches;observer options are synced before pulling so the deferred options
effect sees a no-op diff.
on both sides (the Solid Router "feed
query()intocreateProjection"shape): the derive asks the cache for the answer and the engine owns
everything else — the server runs it, waits, and serializes the settled
value (
deferStreamholds the flush); pending reads suspend into<Loading>; a hydrating client adopts the serialized value withdefault ('server') semantics — server truth owns the DOM for the whole
hydration window, and cache writes that land mid-stream commit when the
stream closes via Solid's hydration-end divergence takeover (requires
solid-js > 2.0.0-rc.3, where a latched node's mid-stream divergence is
deferred rather than lost). Deep reads are fine-grained (a
component reading
data[0].namere-runs only when that leaf changes),and every landing — refetch, invalidation, placeholder upgrade —
reconciles into the existing proxy graph instead of replacing it, so
item identity survives across fetches. The identity key is the
reconcileoption (default'id', function ornullfor positional).Primitives ride a wrapper leaf. A per-hook
primedgate keeps thepull-model fetch from racing hydration: until the hook's own serialized
node entry has primed the cache (or was found absent), the read parks
instead of fetching data the stream already carries.
createProjectionreconciled per-field from querystate — a component reading only
isFetchingre-runs on fetchStatusflips, not every state transition.
isFetchingis the OR of two channels: committedfetchStatus(firstloads, imperative reads) and the engine's pending probe (refetches
inside held transitions, where committed state cannot flip mid-hold by
design).
chainOnce), so mid-flightversion bumps don't hand the engine a new pending promise per recompute.
queryClientaccessor is honored mid-life: the versionsubscription and observer are re-pointed when the client changes.
refetch()syncs the observer to the latest computed options at calltime — under a held transition the setOptions render effect is
deferred, and an imperative refetch must target what the UI is
currently asking for.
SSR + streaming hydration
Cache transfer is content-addressed — the Solid Router
query()pattern,one hydration story, no adapter-owned channel. On the server the provider
serializes every query the request touches into Solid's hydration
registry under
sq:<queryHash>({ data, t: dataUpdatedAt }, rawpre-select data). Serialization happens at fetch-DISPATCH time, while the
request's serialization context is live, by handing seroval the fetch
promise; the payload streams whenever the fetch settles, and entries the
data nodes also serialized are emitted once (seroval cross-reference
dedupe). On the client each hook looks its own hash up in the registry,
reconstructs the cache entry through query-core
hydrate()— stalenesspolicy intact — and only then attaches its observer. Settled entries
prime synchronously at setup; streamed entries prime as their chunk
lands, per query, so early-flushed components go live while later
boundaries still stream.
Content addressing means coverage is the cache's, not the rendered
tree's: a query prefetched in a loader and never read by any component
still transfers, and — like the router — the registry outlives the
hydration window, so a component mounted long after (lazy route,
post-hydration navigation) adopts the server's payload instead of
refetching. Entries are consumed one-shot. External
hydrate()hosts(the TanStack Start pattern) coexist: re-priming the same entry is silent
under newer-wins.
Contracts pinned by the SSR fixture tests:
serializes, and every exposed field honors it. Server meta reads of an
unsettled enabled query tie themselves to the data node and suspend;
isFetching/isPendingcan never serializetrue; disabled-pendingserializes as-is ('pending' IS its settled truth).
both server and client (some inert), because hydration id assignment is
positional.
or while a persister is restoring; values come from adoption or node
priming, and the post-attach observer issues any genuinely-needed fetch
after the window closes.
useIsFetching,useIsMutating,useMutationState): serialize their empty value (a hydrating clientcan only observe 0/[] at claim time) and stay latched through the
hydration window, going live from the first post-hydration effect.
Mutations (
useMutation)Rides Solid core's
actionprimitive: one transaction across the asyncgap, optimistic overlays with automatic rollback, and atomic settle.
Lifecycle callbacks are stripped from core's
Mutation.executeandre-run inside the transaction post-yield, preserving query-core's
callback-rejection semantics (success-path callback failures fail the
mutation; error-path failures report unhandled without displacing the
original error). Invalidation-triggered refetches hold the settle
transition until fresh data lands — mutation settle and cache refresh
commit together.
mutatereturns the mutation promise;mutateAsyncis removed (there isno longer a fire-and-forget vs promise split to justify two entry
points).
The rest
useQueries: N base-query layers underrepeat(positional identity),combined through a memo; the queries array is reactive in length and
content.
useInfiniteQuery: same read layer with_type: 'infinite'stamped soreactive key changes keep
infiniteQueryBehavior; pagers are memos overthe same entry.
createCacheAggregate):a durable signal + optimistic override (cache events firing inside an
action transaction stay observable mid-flight; out-of-transaction
writes stay durable) with the hydration latch described above.
Surface changes (breaking)
datais non-optional on query results: reads suspend (or throw to theboundary) instead of ever returning
undefined. The<Show when={q.data}>loading-guard idiom is dead code now — JSX reads reachparity with plain Solid async reads.
mutateAsyncremoved;mutatereturns a promise.reconcileoption re-shaped: it is now the reconciliation KEY for thedata store (
string | (item => any) | null, default'id') — Solid 2'sprojections auto-reconcile their returns, so the v5 mechanism (manual
reconcile()merges into an adapter-owned store, with astructuredCloneseed hack) is core behavior now. The v5 function form(oldData, newData) => newDatais gone; corestructuralSharingstillcovers cache-level referential stability underneath.
suspenseoption removed: suspension is the model, and boundaries arethe control point.
create*runtime aliases (createQuery,createMutation,createQueries,createInfiniteQuery,createIsFetching,createIsMutating,createMutationState) and theCreate*typealiases are removed, completing the deprecation from fix(solid-query): deprecate
createin favor ofuse, and add full docs #8950 —use*isthe only naming, matching the docs.
deferStreamis now actually implemented — on the v6 line it wasdeclared in the types but wired to nothing. It passes straight through
to Solid 2's per-computation
deferStreamoption on the data node: theserver holds the stream flush until the query resolves instead of
letting the surrounding boundary render its fallback (engine behavior,
covered by solid core's stream tests; ignored on the client).
placeholderDatakeeps React semantics (client-progressive, no serverfetch,
isPlaceholderDataprovenance flag, masked 'success' status)and composes with SSR: the placeholder serializes, the client shows it
through hydration and fetches for real after its window closes.
Intended divergences (documented in-tests)
actively read: the pull model re-demands the value (
it.skipwithrationale in
useQuery.test.tsx). Unmount, disable, or remove thequery to stop it.
isPaused,failureCount) inside an actiontransaction holds its pre-flight face until settle — held updates
commit atomically. The mutation cache remains imperatively inspectable
mid-flight.
Verification
tsc --buildclean. Suites were ported test-by-test with per-file portingnotes; tests asserting store/observer mechanics (render counts,
notification batching) were dropped, semantics tests were kept or
rewritten to DOM-level assertions.
queries; SSR fixture harness (real vite server/client builds, jsdom
hydrate()against real server HTML + streamed payload) coveringstring SSR, streaming with held-open boundaries, fully-buffered late
hydration, prefetched-never-rendered transfer with post-hydration
adoption, external-
hydrate()coexistence (TanStack Start pattern),and the settled-only serialization contracts.
(solid-js rc.0 and rc.3, production SSR server) with this adapter
swapped in — suite green, hydration clean, and single-flight mutations
verified end-to-end in a browser (login/rename/logout flows, mutation
responses refreshing multiple queries with zero invalidation calls and
zero extra fetches).
adapter's core (~1,850), which additionally ships its suspense hook
surface and Next.js-only streaming SSR as separate packages; this
adapter's streaming hydration is included and framework-agnostic. The
primitive inventory is strictly simpler.
Notes
this PR leans on: the hydration-end divergence takeover (a latched
node's mid-stream cache write commits when the stream closes instead of
being lost). It is on solid's
nextbranch; CI will fail againstpublished rc.3 until it ships. The adapter performs no server reactive
writes, per rc.3's deprecation.
action-transaction view.
useQueriesrows ride the same per-hook node priming automatically(each row is a full base-query layer).