@@ -597,38 +410,20 @@ describe('PersistQueryClientProvider', () => {
}
render(() => (
-
+ loading}>
))
- await vi.advanceTimersByTimeAsync(10)
- expect(screen.getByText('hydrated')).toBeInTheDocument()
- await vi.advanceTimersByTimeAsync(10)
+ // The read follows the reactive client accessor, so it is already
+ // pointed at the second client before the restore lands. The restore
+ // still runs exactly once, against the client the provider started
+ // with, so the swapped-in client fetches its own value.
+ await vi.advanceTimersByTimeAsync(20)
expect(screen.getByText('queryFn2')).toBeInTheDocument()
+ expect(screen.getByText('fetchStatus: idle')).toBeInTheDocument()
expect(queryFn1).toHaveBeenCalledTimes(0)
- expect(queryFn2).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
-
- expect(states).toHaveLength(3)
-
- expect(states[0]).toStrictEqual({
- status: 'pending',
- fetchStatus: 'idle',
- data: undefined,
- })
-
- expect(states[1]).toStrictEqual({
- status: 'success',
- fetchStatus: 'fetching',
- data: 'hydrated',
- })
-
- expect(states[2]).toStrictEqual({
- status: 'success',
- fetchStatus: 'idle',
- data: 'queryFn2',
- })
})
})
diff --git a/packages/solid-query/package.json b/packages/solid-query/package.json
index 18b16f8dbd..55347d9a46 100644
--- a/packages/solid-query/package.json
+++ b/packages/solid-query/package.json
@@ -72,14 +72,14 @@
"@babel/preset-typescript": "^7.18.6",
"@solidjs/testing-library": "^0.8.10",
"@solidjs/vite-plugin": "^3.0.0-next.27",
- "@solidjs/web": "^2.0.0-rc.0",
+ "@solidjs/web": "^2.0.0-rc.3",
"@tanstack/query-test-utils": "workspace:*",
- "babel-preset-solid": "^2.0.0-rc.0",
+ "babel-preset-solid": "^2.0.0-rc.2",
"npm-run-all2": "^5.0.0",
- "solid-js": "^2.0.0-rc.0",
+ "solid-js": "^2.0.0-rc.4",
"tsup-preset-solid": "^2.2.0"
},
"peerDependencies": {
- "solid-js": ">=2.0.0-rc.0 <3.0.0"
+ "solid-js": ">=2.0.0-rc.4 <3.0.0"
}
}
diff --git a/packages/solid-query/src/QueryClient.ts b/packages/solid-query/src/QueryClient.ts
index 998c92b808..2cd25c9fde 100644
--- a/packages/solid-query/src/QueryClient.ts
+++ b/packages/solid-query/src/QueryClient.ts
@@ -9,63 +9,42 @@ import type {
QueryKey,
} from '@tanstack/query-core'
-export interface QueryObserverOptions<
+/**
+ * Core observer options pass through unchanged. The old adapter omitted
+ * `structuralSharing` and replaced it with a store-level `reconcile`
+ * option; with reads derived directly from cache state, core's
+ * cache-level structural sharing is exactly what keeps the data memo
+ * referentially stable, so it is exposed again and `reconcile` is gone.
+ */
+export type QueryObserverOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
TPageParam = never,
-> extends OmitKeyof<
- QueryCoreObserverOptions<
- TQueryFnData,
- TError,
- TData,
- TQueryData,
- TQueryKey,
- TPageParam
- >,
- 'structuralSharing'
-> {
- /**
- * Set this to a reconciliation key to enable reconciliation between query results.
- * Set this to `false` to disable reconciliation between query results.
- * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom reconciliation logic.
- * Defaults reconciliation to false.
- */
- reconcile?:
- | string
- | false
- | ((oldData: TData | undefined, newData: TData) => TData)
-}
+> = QueryCoreObserverOptions<
+ TQueryFnData,
+ TError,
+ TData,
+ TQueryData,
+ TQueryKey,
+ TPageParam
+>
-export interface InfiniteQueryObserverOptions<
+export type InfiniteQueryObserverOptions<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
TPageParam = unknown,
-> extends OmitKeyof<
- QueryCoreInfiniteQueryObserverOptions<
- TQueryFnData,
- TError,
- TData,
- TQueryKey,
- TPageParam
- >,
- 'structuralSharing'
-> {
- /**
- * Set this to a reconciliation key to enable reconciliation between query results.
- * Set this to `false` to disable reconciliation between query results.
- * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom reconciliation logic.
- * Defaults reconciliation to false.
- */
- reconcile?:
- | string
- | false
- | ((oldData: TData | undefined, newData: TData) => TData)
-}
+> = QueryCoreInfiniteQueryObserverOptions<
+ TQueryFnData,
+ TError,
+ TData,
+ TQueryKey,
+ TPageParam
+>
export interface DefaultOptions<
TError = DefaultError,
diff --git a/packages/solid-query/src/QueryClientProvider.tsx b/packages/solid-query/src/QueryClientProvider.tsx
index 9f2beb95e4..f615275efc 100644
--- a/packages/solid-query/src/QueryClientProvider.tsx
+++ b/packages/solid-query/src/QueryClientProvider.tsx
@@ -1,21 +1,18 @@
-import {
- createContext,
- createRenderEffect,
- createSignal,
- onCleanup,
- useContext,
-} from 'solid-js'
-import {
- HydrationCoordinatorContext,
- createHydrationCoordinator,
- createServerDehydrationChannel,
-} from './hydrationChannel'
-import type { DehydrationChannelYield } from './hydrationChannel'
+import { createContext, onCleanup, sharedConfig, useContext } from 'solid-js'
+import type { Query } from '@tanstack/query-core'
import type { QueryClient } from './QueryClient'
import type { JSX } from '@solidjs/web'
const isServer = typeof window === 'undefined'
+/**
+ * Namespace prefix for cache entries in Solid's hydration registry — the
+ * registry is shared with positional node ids and other libraries'
+ * content-addressed keys (Solid Router uses its own cache keys), so query
+ * hashes get their own prefix.
+ */
+export const HYDRATION_KEY_PREFIX = 'sq:'
+
export const QueryClientContext = createContext<(() => QueryClient) | null>(
null,
)
@@ -38,59 +35,81 @@ export type QueryClientProviderProps = {
children?: JSX.Element
}
+/**
+ * Server side of hydration: serialize every query the request touches into
+ * Solid's hydration registry, content-addressed by query hash (the Solid
+ * Router `query()` pattern). Serialization happens at fetch-DISPATCH time —
+ * synchronously, while the request's serialization context is live — by
+ * handing seroval the fetch promise; it streams the `{ data, t }` payload
+ * whenever the fetch settles. Settled entries (initialData, setQueryData,
+ * loader prefetches that already landed) serialize their value directly.
+ *
+ * Content addressing is what makes this cover MORE than rendered
+ * components: a query prefetched in a loader and never read by any
+ * component still transfers, and any client hook (hydrating or mounted
+ * long after) finds it by hash. One entry per hash regardless of how many
+ * hooks read it; the payload object is the same reference the data nodes
+ * serialize, so seroval's cross-reference dedupe emits it once.
+ */
+function serializeCacheOnServer(client: QueryClient): void {
+ const ctx = (
+ sharedConfig as unknown as {
+ context?: {
+ async?: boolean
+ noHydrate?: boolean
+ serialize: (key: string, value: unknown) => void
+ }
+ }
+ ).context
+ if (!ctx || !ctx.async || ctx.noHydrate) return
+
+ const cache = client.getQueryCache()
+ const seen = new Set()
+ const serializeQuery = (query: Query) => {
+ if (seen.has(query.queryHash)) return
+ const state = query.state
+ if (state.status === 'success') {
+ seen.add(query.queryHash)
+ ctx.serialize(HYDRATION_KEY_PREFIX + query.queryHash, {
+ data: state.data,
+ t: state.dataUpdatedAt,
+ })
+ } else if (state.fetchStatus !== 'idle') {
+ // Serialize the PROMISE now, while the context is live — the settle
+ // event fires from IO, where no request context exists anymore.
+ const promise = query.promise as Promise | undefined
+ if (!promise) return
+ seen.add(query.queryHash)
+ ctx.serialize(
+ HYDRATION_KEY_PREFIX + query.queryHash,
+ promise.then(() => ({
+ data: query.state.data,
+ t: query.state.dataUpdatedAt,
+ })),
+ )
+ }
+ }
+
+ for (const query of cache.getAll()) serializeQuery(query)
+ onCleanup(cache.subscribe((event) => serializeQuery(event.query)))
+}
+
+/**
+ * Provides the QueryClient and manages its mount lifecycle. On the server
+ * it also registers the cache serializer above; on the client, hooks prime
+ * the cache from their hash-keyed registry entries themselves — see
+ * `useBaseQuery`.
+ */
export const QueryClientProvider = (
props: QueryClientProviderProps,
): JSX.Element => {
props.client.mount()
onCleanup(() => props.client.unmount())
-
- // Library-owned serialization channel for SSR dehydration.
- //
- // Server: the computation's value IS the channel's async iterable, so
- // Solid serializes it through its normal per-computation signal path:
- // the server runtime tees the iterator into the hydration serializer
- // (`ctx.serialize(id, tapped)` in solid-js' `processResult`) and
- // seroval streams each cumulative dehydrated-cache snapshot to the
- // client as a chunk riding the SSR stream, with the entry objects
- // inside deduplicated by reference against everything else in the
- // payload. Nothing reads the signal during SSR, so it never suspends
- // anything.
- //
- // Client, hydrating: Solid replays the serialized iterable through the
- // per-computation signal path (`hydrateSignalFromAsyncIterable`).
- // Yields that were still buffered when hydration began are conflated
- // to the LATEST yield (`normalizeIterator`) — lossless here because
- // every yield is a cumulative snapshot — and live yields after that
- // apply one at a time. Requires a solid-js build with the buffered
- // async-iterable replay conflation fix (> 2.0.0-beta.32): before it,
- // the replay dropped every buffered yield after the first, including
- // the terminal `done` snapshot. The render effect below hands each
- // signal value to the coordinator, which primes the QueryClient via
- // query-core hydrate() (newer-wins) and unblocks `useBaseQuery`
- // subscribers waiting on their query's entry.
- //
- // Client, fresh mount: the compute returns undefined and the effect
- // never fires.
- const [channelValue] = createSignal(
- () => (isServer ? createServerDehydrationChannel(props.client) : undefined),
- )
- const coordinator = isServer
- ? null
- : createHydrationCoordinator(() => props.client)
- createRenderEffect(
- () => (isServer ? undefined : channelValue()),
- (value) => {
- if (value && coordinator) {
- coordinator.applyYield(value)
- }
- },
- )
+ if (isServer) serializeCacheOnServer(props.client)
return (
props.client}>
-
- {props.children}
-
+ {props.children}
)
}
diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx
index 16f1845f8e..58fcc6eba9 100644
--- a/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx
+++ b/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx
@@ -6,13 +6,21 @@
* (consumed by `entry-server.tsx`) and once with the hydratable DOM
* transform (consumed by `entry-client.tsx`).
*/
-import { Loading } from 'solid-js'
-import { QueryClientProvider, useQuery } from '@tanstack/solid-query'
+import { Loading, Show } from 'solid-js'
+import {
+ QueryClientProvider,
+ useIsFetching,
+ useQuery,
+} from '@tanstack/solid-query'
import type { QueryClient } from '@tanstack/solid-query'
+const isServer = typeof window === 'undefined'
+
export interface FetchCounts {
fresh: number
stale: number
+ placeholder: number
+ prefetched: number
}
export interface AppProps {
@@ -20,6 +28,10 @@ export interface AppProps {
/** Marker baked into the query data so tests can tell where it was fetched. */
source: 'server' | 'client'
counts: FetchCounts
+ /** Toggled by tests after hydration to mount a subtree that was never
+ * rendered on the server — its query must adopt the server's prefetched
+ * payload from the registry instead of refetching. */
+ lateMount?: () => boolean
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -36,6 +48,11 @@ function Queries(props: AppProps) {
staleTime: 60_000,
}))
+ // Cross-cache aggregate: must serialize the hydration-time truth (0 —
+ // the hydrating client's fetches are held and primed entries are
+ // settled) and stay latched there through the hydration window.
+ const fetching = useIsFetching()
+
// Immediately stale: normal staleness rules mean this refetches on mount.
const stale = useQuery(() => ({
queryKey: ['stale'],
@@ -47,19 +64,80 @@ function Queries(props: AppProps) {
staleTime: 0,
}))
+ // Placeholder short-circuit: the data compute serves the placeholder
+ // before the fetch-pull branch, so this must NOT fetch during SSR — the
+ // placeholder itself is the serialized output (no boundary hold, raw
+ // meta stays 'pending' which is its settled SSR truth), and the client
+ // fetches for real only after its hydration window closes.
+ const placeholder = useQuery(() => ({
+ queryKey: ['placeholder'],
+ queryFn: async () => {
+ props.counts.placeholder++
+ await sleep(5)
+ return `placeholder-resolved-${props.source}`
+ },
+ placeholderData: 'placeholder-value',
+ staleTime: 60_000,
+ }))
+
return (
{fresh.data}{stale.data}
+ {/* Meta guards: boundaries serialize settled state only, so these
+ must show settled values in the server HTML — a transient
+ ('pending', fetching) here is a hydration mismatch in waiting. */}
+
+ {fresh.status}|{String(fresh.isFetching)}|{String(fresh.isSuccess)}|
+ {String(fresh.isFetchedAfterMount)}
+
+ {fetching()}
+
+ {placeholder.data}|{String(placeholder.isPlaceholderData)}|
+ {placeholder.status}
+
)
}
+/** Never rendered on the server — mounted by tests after hydration. Its
+ * query was prefetched (and only prefetched) during SSR; the hash-keyed
+ * registry entry must satisfy it with zero client fetches. */
+function LateConsumer(props: AppProps) {
+ const late = useQuery(() => ({
+ queryKey: ['prefetched'],
+ queryFn: async () => {
+ props.counts.prefetched++
+ await sleep(5)
+ return `prefetched-${props.source}`
+ },
+ staleTime: 60_000,
+ }))
+ return {late.data}
+}
+
export function App(props: AppProps) {
+ // Cache coverage beyond the rendered tree: prefetch a query no component
+ // reads during this render. Fired synchronously during setup, so the
+ // provider's serializer catches the fetch dispatch while the request's
+ // serialization context is live.
+ if (isServer) {
+ void props.client.prefetchQuery({
+ queryKey: ['prefetched'],
+ queryFn: async () => {
+ props.counts.prefetched++
+ await sleep(5)
+ return `prefetched-${props.source}`
+ },
+ })
+ }
return (
loading
}>
+
+
+
)
diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx
index f2df928c2b..9a67bea7b7 100644
--- a/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx
+++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx
@@ -5,6 +5,7 @@
* QueryClient/counters per call so multiple tests can share the bundle.
*/
import { hydrate } from '@solidjs/web'
+import { createSignal } from 'solid-js'
import { QueryClient } from '@tanstack/solid-query'
import { App } from './App'
import { StreamApp } from './StreamApp'
@@ -13,13 +14,28 @@ import type { StreamCounts } from './StreamApp'
export function createApp() {
const queryClient = new QueryClient()
- const counts: FetchCounts = { fresh: 0, stale: 0 }
+ const counts: FetchCounts = {
+ fresh: 0,
+ stale: 0,
+ placeholder: 0,
+ prefetched: 0,
+ }
+ const [lateMount, setLateMount] = createSignal(false)
return {
queryClient,
counts,
+ /** Mount the subtree the server never rendered (see `LateConsumer`). */
+ showLate: () => setLateMount(true),
mount(container: HTMLElement): () => void {
return hydrate(
- () => ,
+ () => (
+
+ ),
container,
)
},
diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx
index c730da6421..e96396c264 100644
--- a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx
+++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx
@@ -10,7 +10,12 @@ import { App } from './App'
import type { FetchCounts } from './App'
const client = new QueryClient()
-const counts: FetchCounts = { fresh: 0, stale: 0 }
+const counts: FetchCounts = {
+ fresh: 0,
+ stale: 0,
+ placeholder: 0,
+ prefetched: 0,
+}
// Fully-settled single-string render. Collected through pipe() rather than
// the thenable form so the fixture builds against any solid-js 2 beta
diff --git a/packages/solid-query/src/__tests__/hydration-utils.ts b/packages/solid-query/src/__tests__/hydration-utils.ts
index 12d83b8341..9b53daad17 100644
--- a/packages/solid-query/src/__tests__/hydration-utils.ts
+++ b/packages/solid-query/src/__tests__/hydration-utils.ts
@@ -31,7 +31,12 @@ interface QuerySnapshot {
export interface ServerReport {
string: {
html: string
- counts: { fresh: number; stale: number }
+ counts: {
+ fresh: number
+ stale: number
+ placeholder: number
+ prefetched: number
+ }
queries: Array
}
stream: {
@@ -44,7 +49,13 @@ export interface ServerReport {
export interface ClientBundle {
createApp: () => {
queryClient: QueryClient
- counts: { fresh: number; stale: number }
+ counts: {
+ fresh: number
+ stale: number
+ placeholder: number
+ prefetched: number
+ }
+ showLate: () => void
mount: (container: HTMLElement) => () => void
}
createStreamApp: () => {
diff --git a/packages/solid-query/src/__tests__/hydration.test.tsx b/packages/solid-query/src/__tests__/hydration.test.tsx
index 74581ad130..f62c04d55d 100644
--- a/packages/solid-query/src/__tests__/hydration.test.tsx
+++ b/packages/solid-query/src/__tests__/hydration.test.tsx
@@ -34,17 +34,69 @@ afterAll(() => {
})
describe('SSR hydration', () => {
- it('server render produces data and the dehydration channel payload', () => {
+ it('server render produces data and content-addressed cache payloads', () => {
const { string } = harness.report
- expect(string.counts).toEqual({ fresh: 1, stale: 1 })
+ // The placeholder query must NOT fetch during SSR: the data compute
+ // serves the placeholder before the fetch-pull branch, so the
+ // placeholder itself is the render output.
+ expect(string.counts).toEqual({
+ fresh: 1,
+ stale: 1,
+ placeholder: 0,
+ prefetched: 1,
+ })
expect(string.html).toContain('fresh-server')
expect(string.html).toContain('stale-server')
- // The provider's dehydration channel serializes cumulative snapshots of
- // dehydrated cache entries (query-core dehydrate shapes)...
- expect(string.html).toContain('dehydratedAt')
- expect(string.html).toContain('"[\\"fresh\\"]"')
+ // Cache entries ride Solid's hydration registry content-addressed by
+ // query hash (`sq:` → { data, t }) — `t` (dataUpdatedAt) lets
+ // the hydrating client reconstruct the entry with staleness intact,
+ // and hash addressing means coverage is the cache's, not the rendered
+ // tree's. There is no separate dehydration channel...
+ expect(string.html).toMatch(
+ /sq:\[\\"fresh\\"\]"\]=[^<]*data:"fresh-server"/,
+ )
+ expect(string.html).toMatch(
+ /sq:\[\\"stale\\"\]"\]=[^<]*data:"stale-server"/,
+ )
+ expect(string.html).toMatch(/\{data:"fresh-server",t:\d+\}/)
+ // Content addressing covers the whole cache: this query was prefetched
+ // during SSR and never rendered by any component, yet it transfers.
+ // (It serializes at fetch-dispatch time as a promise ref — the key
+ // registers up front and the payload streams in a later fulfillment
+ // script, so the two are asserted separately.)
+ expect(string.html).toMatch(/sq:\[\\"prefetched\\"\]/)
+ expect(string.html).toMatch(/\{data:"prefetched-server",t:\d+\}/)
+ expect(string.html).not.toContain('dehydratedAt')
// ...and the per-observer-result hydrationData copy is gone.
expect(string.html).not.toContain('hydrationData')
+
+ // Meta guards serialize SETTLED state only (status|isFetching|isSuccess|
+ // isFetchedAfterMount from the fixture's #meta span). Boundaries hold
+ // until async settles; meta must honor the same contract — a transient
+ // 'pending'/'fetching'/isFetchedAfterMount-true here would contradict
+ // what the hydrating client computes from the primed cache.
+ const meta = /]*>(.*?)<\/span>/.exec(string.html)![1]!
+ expect(meta.replace(//g, '')).toBe('success|false|true|false')
+
+ // Cross-cache aggregates (useIsFetching) serialize the hydration-time
+ // truth: the hydrating client's own fetches are held inside the window
+ // and primed entries land settled, so it can only ever compute 0 at
+ // claim time — any other serialized value is a structural mismatch in
+ // waiting (a server-side in-flight count does not transfer).
+ const global = /]*>(.*?)<\/span>/.exec(string.html)![1]!
+ expect(global.replace(//g, '')).toBe('0')
+
+ // Placeholder serializes AS the placeholder (data|isPlaceholderData|
+ // status): no boundary hold, the status face masks to 'success' on
+ // both sides, and the hydrating client computes the identical
+ // placeholder from its unprimed cache — no mismatch, no undermining
+ // of the streamed payload (a placeholder derive settles synchronously,
+ // so its node serializes nothing and the client fetches after its
+ // window closes).
+ const ph = /]*>(.*?)<\/span>/.exec(string.html)![1]!
+ expect(ph.replace(//g, '')).toBe(
+ 'placeholder-value|true|success',
+ )
})
it('hydration primes the query cache and refetches only per staleness rules', async () => {
@@ -67,11 +119,11 @@ describe('SSR hydration', () => {
const dispose = app.mount(container)
try {
- // Cache must be warm within microtasks of hydration (the provider
- // consumes the deserialized channel; entries apply before the mount
- // task's microtask queue drains, i.e. before a browser would paint):
- // same data and the server's dataUpdatedAt (hydrate() newer-wins
- // semantics).
+ // Cache must be warm within microtasks of hydration (each hook
+ // primes from its own node entry at setup, before a browser would
+ // paint): same data and the server's dataUpdatedAt — `t` rides the
+ // serialized root, and priming goes through hydrate() newer-wins
+ // semantics.
await microtasks()
const serverFresh = string.queries.find(
(q) => q.queryHash === '["fresh"]',
@@ -103,8 +155,18 @@ describe('SSR hydration', () => {
'fresh-server',
)
+ // The placeholder query hydrated showing its placeholder (identical
+ // to the server HTML), then fetched for real once its window closed
+ // (its node had no serialized entry) and swapped in data.
+ await vi.waitFor(() => {
+ expect(app.counts.placeholder).toBe(1)
+ expect(container.querySelector('#ph')?.textContent).toContain(
+ 'placeholder-resolved-client',
+ )
+ })
+
// The serialized observer results no longer carry a hydrationData
- // copy at all — the channel is the only transport.
+ // copy at all — the node payload is the only transport.
const registry = (globalThis as any)._$HY.r as Record
const lingering = Object.values(registry).filter((entry) => {
const value = entry != null && entry.s === 1 ? entry.v : entry
@@ -119,6 +181,52 @@ describe('SSR hydration', () => {
}
})
+ it('a component mounted after hydration adopts a prefetched-never-rendered payload', async () => {
+ // The server prefetched ['prefetched'] in a loader-style call and no
+ // component rendered it. Content-addressed registry entries outlive
+ // the hydration window (the Solid Router pattern), so a subtree
+ // mounted later — lazy route, user interaction — adopts the server's
+ // payload instead of refetching.
+ const { string } = harness.report
+ const app = bundle.createApp()
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ bootstrapHydrationGlobals()
+ container.innerHTML = string.html
+ for (const script of Array.from(container.querySelectorAll('script'))) {
+ if (script.textContent) window.eval(script.textContent)
+ script.remove()
+ }
+
+ const dispose = app.mount(container)
+ try {
+ // Nothing consumed the prefetched entry during hydration: the cache
+ // has no such query and the registry still holds the payload.
+ await tick(30)
+ expect(app.queryClient.getQueryState(['prefetched'])).toBeUndefined()
+
+ // Mount the consumer long after hydration completed.
+ app.showLate()
+ await vi.waitFor(() => {
+ expect(container.querySelector('#late')?.textContent).toBe(
+ 'prefetched-server',
+ )
+ })
+ // Adopted with zero client fetches — and staleness metadata came
+ // across with it.
+ expect(app.counts.prefetched).toBe(0)
+ const serverPrefetched = string.queries.find(
+ (q) => q.queryHash === '["prefetched"]',
+ )!
+ expect(app.queryClient.getQueryState(['prefetched'])?.dataUpdatedAt).toBe(
+ serverPrefetched.state.dataUpdatedAt,
+ )
+ } finally {
+ dispose()
+ container.remove()
+ }
+ })
+
it('applies cache writes that land between priming and subscriber attach', async () => {
const { string } = harness.report
const app = bundle.createApp()
@@ -153,13 +261,12 @@ describe('SSR hydration', () => {
})
it('coexists with a host that primes the cache through its own hydrate() channel', async () => {
- // TanStack Start does not use the provider channel: its router
- // integration applies a dehydrated QueryClient via query-core hydrate()
- // before Solid's DOM hydrate() runs. Under Start both channels are live
- // and prime the same entries; pin that the second application is silent
- // (hydrate() only writes strictly-newer data, so equal dataUpdatedAt is
- // a no-op with no observer notification) and that the provider's attach
- // coordination still resolves.
+ // TanStack Start-style hosts apply a dehydrated QueryClient via
+ // query-core hydrate() before Solid's DOM hydrate() runs. The hooks'
+ // own node priming then re-applies the same entries; pin that the
+ // second application is silent (hydrate() only writes strictly-newer
+ // data, so equal dataUpdatedAt is a no-op with no observer
+ // notification) and that attach coordination still resolves.
const { string } = harness.report
const app = bundle.createApp()
const container = document.createElement('div')
@@ -254,11 +361,10 @@ describe('streaming SSR hydration', () => {
const dispose = app.mount(container)
try {
- // The header entry settled before the first flush, so its channel
- // yield rides the same chunks — it must be primed within microtasks
- // of shell hydration, with the server's dataUpdatedAt intact. This
- // also proves the mocked-Promise hydration replay does not wedge the
- // provider's stream consumption.
+ // The header query settled before the first flush, so its node
+ // entry rides the same chunks — the hook primes from it within
+ // microtasks of shell hydration, with the server's dataUpdatedAt
+ // intact (`t` on the serialized root).
await microtasks()
const serverHeader = harness.report.stream.queries.find(
(q) => q.queryHash === '["header"]',
@@ -298,16 +404,13 @@ describe('streaming SSR hydration', () => {
}
})
- it('applies the latest cumulative snapshot when hydration starts after the whole stream arrived (buffered-replay conflation)', async () => {
- // Hydration long after the stream completed (slow client / late script):
- // every channel yield — one per settle plus the terminal done snapshot —
- // is already buffered in the deserialized stream when the provider's
- // signal replays. Solid's signal-path replay conflates that backlog to
- // the LATEST yield (`normalizeIterator`), which is lossless precisely
- // because yields are cumulative. All entries must be primed and all
- // observers attached from that one snapshot; on solid builds without
- // the conflation fix (<= 2.0.0-beta.32) the replay pins at the first
- // yield and everything after it (including `done`) is dropped.
+ it('primes everything when hydration starts after the whole stream arrived (buffered replay)', async () => {
+ // Hydration long after the stream completed (slow client / late
+ // script): every node entry is already settled in the registry when
+ // the components mount, so each hook primes synchronously at setup
+ // from its own entry. All entries primed, all observers attached, no
+ // refetches — and the takeover recompute at hydration end must not
+ // race ahead of a not-yet-primed hook (the `primed` gate).
const { chunks } = harness.report.stream
const app = bundle.createStreamApp()
const container = document.createElement('div')
@@ -364,7 +467,14 @@ describe('streaming SSR hydration', () => {
}
})
- it('hydrated components are live while the stream is still open', async () => {
+ // The data node has default ('server') hydration semantics: the
+ // serialized server value owns the DOM for the whole hydration window,
+ // and a latched node that recomputes mid-stream (a cache write landed)
+ // arms the engine's hydration-end takeover — the change commits when the
+ // stream closes, deferred rather than lost. Network activity is NOT
+ // deferred: observers attach per-query as the channel primes, so
+ // mid-stream invalidations refetch immediately.
+ it('cache writes during the open stream commit when hydration completes', async () => {
const { phase1, phase2 } = splitStream()
const app = bundle.createStreamApp()
const container = document.createElement('div')
@@ -383,34 +493,48 @@ describe('streaming SSR hydration', () => {
// The slow section still shows its fallback.
expect(container.querySelector('#feed')).toBeNull()
- // Newer data written while the stream is open must reach the
- // already-hydrated component without waiting for the stream to end.
+ // A write while the stream is open reaches the CACHE immediately but
+ // not the DOM — the serialized server value holds the document.
app.queryClient.setQueryData(['header'], 'updated-client')
- await vi.waitFor(() => {
- expect(container.querySelector('#header')?.textContent).toBe(
- 'updated-client',
- )
- })
+ await tick(30)
+ expect(app.queryClient.getQueryData(['header'])).toBe('updated-client')
+ expect(container.querySelector('#header')?.textContent).toBe(
+ 'header-server',
+ )
- // An invalidation while the stream is open refetches the hydrated
- // query immediately (it is active — its observer is subscribed).
+ // An invalidation while the stream is open refetches immediately
+ // (the observer is subscribed — network is not deferred), but the
+ // result is held with the rest.
void app.queryClient.invalidateQueries({ queryKey: ['header'] })
await vi.waitFor(() => {
expect(app.counts.header).toBe(1)
- expect(container.querySelector('#header')?.textContent).toBe(
- 'header-client',
- )
})
+ expect(container.querySelector('#header')?.textContent).toBe(
+ 'header-server',
+ )
- // The late boundary still hydrates correctly afterwards.
+ // The late boundary hydrates, closing the stream — the divergence
+ // takeover re-runs the latched node and the held state commits.
applyChunks(container, phase2)
await vi.waitFor(() => {
expect(container.querySelector('#feed')?.textContent).toBe(
'feed-server',
)
})
+ await vi.waitFor(() => {
+ expect(container.querySelector('#header')?.textContent).toBe(
+ 'header-client',
+ )
+ })
expect(app.counts.feed).toBe(0)
- await tick(30)
+
+ // And fully live from then on.
+ app.queryClient.setQueryData(['header'], 'updated-live')
+ await vi.waitFor(() => {
+ expect(container.querySelector('#header')?.textContent).toBe(
+ 'updated-live',
+ )
+ })
} finally {
dispose()
container.remove()
diff --git a/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx b/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx
index 5d212f7edf..60959bc10e 100644
--- a/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx
+++ b/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx
@@ -51,8 +51,9 @@ describe('infiniteQueryOptions', () => {
initialPageParam: 0,
})
+ // Non-optional even without initialData: `data` is a suspending read.
expectTypeOf(() => useInfiniteQuery(() => options).data).toEqualTypeOf<
- () => InfiniteData<{ wow: boolean }, unknown> | undefined
+ () => InfiniteData<{ wow: boolean }, unknown>
>()
expectTypeOf(options).toExtend<
diff --git a/packages/solid-query/src/__tests__/queryOptions.test-d.tsx b/packages/solid-query/src/__tests__/queryOptions.test-d.tsx
index 299536290e..337230c67b 100644
--- a/packages/solid-query/src/__tests__/queryOptions.test-d.tsx
+++ b/packages/solid-query/src/__tests__/queryOptions.test-d.tsx
@@ -37,7 +37,9 @@ describe('queryOptions', () => {
})
const { data } = useQuery(() => options)
- expectTypeOf(data).toEqualTypeOf()
+ // Non-optional: `data` is a suspending read — the pre-fetch gap suspends
+ // into instead of surfacing as `undefined`.
+ expectTypeOf(data).toEqualTypeOf()
})
it('should work when passed to fetchQuery', async () => {
const options = queryOptions({
diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx
index bc30409acf..d2e6f88559 100644
--- a/packages/solid-query/src/__tests__/suspense.test.tsx
+++ b/packages/solid-query/src/__tests__/suspense.test.tsx
@@ -1,12 +1,16 @@
+// Legacy suspense suite ported to the 2.0 model, where suspense IS the model:
+// the `suspense` option is gone, first loads suspend into , errors
+// with no committed data surface to , and refetches/key switches
+// hold the committed UI. Tests that only exercised the old opt-in flag or
+// render-count mechanics were deleted; see port-notes/suspense.md.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent } from '@solidjs/testing-library'
-import { Errored, Loading, createRenderEffect, createSignal } from 'solid-js'
+import { Errored, Loading, createSignal } from 'solid-js'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { QueryCache, QueryClient, useInfiniteQuery, useQuery } from '..'
import { renderWithClient } from './utils'
-import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '..'
-describe("useQuery's in Loading mode", () => {
+describe('useQuery suspense semantics (Loading/Errored boundaries)', () => {
let queryCache: QueryCache
let queryClient: QueryClient
@@ -21,87 +25,26 @@ describe("useQuery's in Loading mode", () => {
vi.useRealTimers()
})
- it('should render the correct amount of times in Loading mode', async () => {
- const key = queryKey()
- const states: Array> = []
-
- let count = 0
- let renders = 0
-
- function Page() {
- const [stateKey, setStateKey] = createSignal(key)
-
- const state = useQuery(() => ({
- queryKey: stateKey(),
- queryFn: () => sleep(10).then(() => ++count),
- }))
-
- createRenderEffect(
- () => state,
- (s) => {
- states.push({ ...s })
- },
- )
-
- createRenderEffect(
- () => [{ ...state }, () => key],
- () => {
- renders++
- },
- )
-
- return (
-
)
}
@@ -112,16 +55,18 @@ describe("useQuery's in Loading mode", () => {
))
+ expect(rendered.getByText('loading')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('data: 1')).toBeInTheDocument()
- expect(states.length).toBeGreaterThan(0)
- expect(states.at(-1)?.status).toMatch(/pending|success/)
fireEvent.click(rendered.getByText('next'))
- await vi.advanceTimersByTimeAsync(10)
+ await vi.advanceTimersByTimeAsync(5)
+ // Key switch: committed page holds — no fallback.
+ expect(rendered.getByText('data: 1')).toBeInTheDocument()
+ expect(rendered.queryByText('loading')).not.toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
expect(rendered.getByText('data: 2')).toBeInTheDocument()
- expect(states.length).toBeGreaterThan(0)
- expect(states.at(-1)?.status).toMatch(/pending|success/)
})
it('should not call the queryFn twice when used in Loading mode', async () => {
@@ -133,7 +78,6 @@ describe("useQuery's in Loading mode", () => {
useQuery(() => ({
queryKey: [key],
queryFn,
- suspense: true,
}))
return <>rendered>
@@ -210,8 +154,6 @@ describe("useQuery's in Loading mode", () => {
return 'data'
}),
retryDelay: 10,
- suspense: true,
- throwOnError: true,
}))
return (
@@ -267,8 +209,6 @@ describe("useQuery's in Loading mode", () => {
return 'data'
}),
retry: false,
- suspense: true,
- throwOnError: true,
}))
return (
@@ -325,7 +265,6 @@ describe("useQuery's in Loading mode", () => {
queryKey: key,
queryFn: () => sleep(100).then(() => ++count),
retry: false,
- suspense: true,
staleTime: 0,
}))
@@ -366,22 +305,27 @@ describe("useQuery's in Loading mode", () => {
fireEvent.click(rendered.getByText('show'))
await vi.advanceTimersByTimeAsync(0)
+ // Remount serves the cached value immediately while the mount refetch
+ // runs in the background — no fallback.
+ expect(rendered.getByText('data: 1')).toBeInTheDocument()
expect(rendered.getByText('fetching: true')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(100)
expect(rendered.getByText('data: 2')).toBeInTheDocument()
expect(rendered.getByText('fetching: false')).toBeInTheDocument()
})
- it('should suspend when switching to a new query', async () => {
+ it('should hold committed data when switching to a new query', async () => {
const key1 = queryKey()
const key2 = queryKey()
function Component(props: { queryKey: Array }) {
const result = useQuery(() => ({
queryKey: props.queryKey,
- queryFn: () => sleep(100).then(() => props.queryKey),
+ // Read the key from the query context, not from reactive props: a
+ // queryFn resolving during the switch's hold would read the
+ // COMMITTED (old) key from props and poison the new entry.
+ queryFn: (ctx) => sleep(100).then(() => ctx.queryKey),
retry: false,
- suspense: true,
}))
return
data: {result.data}
@@ -407,10 +351,21 @@ describe("useQuery's in Loading mode", () => {
const rendered = renderWithClient(queryClient, () => )
+ expect(rendered.getByText('loading')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(100)
expect(rendered.getByText(`data: ${key1}`)).toBeInTheDocument()
fireEvent.click(rendered.getByText('switch'))
+ await vi.advanceTimersByTimeAsync(50)
+ // Switching keys is a refetch shape, not a fresh first load: the
+ // committed value holds and the fallback does not come back.
+ expect(rendered.getByText(`data: ${key1}`)).toBeInTheDocument()
+ expect(rendered.queryByText('loading')).not.toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(50)
+ expect(rendered.getByText(`data: ${key2}`)).toBeInTheDocument()
+ // Drain the observer's follow-up mount refetch of the new key so no
+ // fetch is left in flight at teardown.
await vi.advanceTimersByTimeAsync(100)
expect(rendered.getByText(`data: ${key2}`)).toBeInTheDocument()
})
@@ -428,7 +383,6 @@ describe("useQuery's in Loading mode", () => {
queryFn: () =>
sleep(10).then(() => Promise.reject(new Error('Loading Error a1x'))),
retry: false,
- suspense: true,
}))
return
rendered {state.data}
@@ -459,6 +413,11 @@ describe("useQuery's in Loading mode", () => {
})
it('should not throw errors to the error boundary when throwOnError: false', async () => {
+ // With no committed data, reading `.data` of an errored query always
+ // routes to — the read cannot produce a value. What
+ // `throwOnError: false` preserves is the error-as-state channel:
+ // metadata reads (status/error) never throw, so a component that does
+ // not read `.data` renders the error without tripping the boundary.
const key = queryKey()
function Page() {
@@ -470,7 +429,12 @@ describe("useQuery's in Loading mode", () => {
throwOnError: false,
}))
- return
rendered {state.data}
+ return (
+
+
status: {state.status}
+
error: {state.error?.message ?? 'none'}
+
+ )
}
function App() {
@@ -491,8 +455,11 @@ describe("useQuery's in Loading mode", () => {
const rendered = renderWithClient(queryClient, () => )
+ expect(rendered.getByText('status: pending')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10)
- expect(rendered.getByText('rendered')).toBeInTheDocument()
+ expect(rendered.getByText('status: error')).toBeInTheDocument()
+ expect(rendered.getByText('error: Loading Error a2x')).toBeInTheDocument()
+ expect(rendered.queryByText('error boundary')).not.toBeInTheDocument()
})
it('should throw errors to the error boundary when a throwOnError function returns true', async () => {
@@ -539,19 +506,30 @@ describe("useQuery's in Loading mode", () => {
})
it('should not throw errors to the error boundary when a throwOnError function returns false', async () => {
+ // The throwOnError gate applies to reads that have a committed value to
+ // keep serving: a failed refetch with stale data stays error-as-state
+ // when the function returns false.
const key = queryKey()
+ let count = 0
function Page() {
const state = useQuery(() => ({
queryKey: key,
queryFn: () =>
- sleep(10).then(() => Promise.reject(new Error('Local Error'))),
+ sleep(10).then(() => {
+ if (++count === 1) return 'data'
+ return Promise.reject(new Error('Local Error'))
+ }),
retry: false,
- suspense: true,
- throwOnError: (err) => err.message !== 'Local Error',
+ throwOnError: (err: Error) => err.message !== 'Local Error',
}))
- return
)
}
const rendered = renderWithClient(queryClient, () => (
-
+ Loading...}>
))
@@ -2020,6 +1381,10 @@ describe('useInfiniteQuery', () => {
expect(rendered.getByText('Nothing more to load')).toBeInTheDocument()
fireEvent.click(rendered.getByText('Remove Last Page'))
+ // Commit the signal write before the refetch dispatches: otherwise the
+ // write joins the refetch's transition hold and the queryFn reads the
+ // stale value for the whole refetch.
+ await vi.advanceTimersByTimeAsync(0)
fireEvent.click(rendered.getByText('Refetch'))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('Background Updating...')).toBeInTheDocument()
@@ -2088,7 +1453,7 @@ describe('useInfiniteQuery', () => {
)
return (