Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dehydrate-settled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-query': minor
---

SSR lifecycle utilities: new `dehydrateSettled` awaits in-flight queries before dehydrating so streamed HTML carries settled data; `QueryClientProvider` tears the client down after server render disposal (`cancelQueries` + `clear`, preventing cross-request leaks) and dehydration now respects `defaultOptions.dehydrate.shouldDehydrateQuery` filtering.
5 changes: 5 additions & 0 deletions .changeset/flight-data-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-query': minor
---

Built-in single-flight consumer: `QueryClientProvider` now subscribes the query cache's slice of Solid's multi-source single-flight channel under the exported `FLIGHT_DATA_SOURCE` id (`"sq"`). Mutation responses carrying that slice — a `DehydratedState` produced by a server collector registered with `registerFlightDataSource(FLIGHT_DATA_SOURCE, hook)` — hydrate the provider's client before the mutation's promise resolves, so every mounted query on those keys updates with no follow-up refetches and no per-app wiring. Subscribing is inert when no server collector exists. Requires the `@solidjs/web` release following 2.0.0-rc.4 (the named-source single-flight protocol).
7 changes: 7 additions & 0 deletions .changeset/solid-rc-6-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/solid-query': patch
'@tanstack/solid-query-devtools': patch
'@tanstack/solid-query-persist-client': patch
---

Require solid-js and @solidjs/web 2.0.0-rc.6+. rc.6 ships the named flight-data source API the single-flight consumer uses, plus the async settle fix the adapter depends on (rc.5's settle-walk regression breaks query hydration).
6 changes: 3 additions & 3 deletions packages/solid-query-devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,14 @@
"devDependencies": {
"@babel/core": "^7.28.0",
"@babel/preset-typescript": "^7.18.6",
"@solidjs/signals": "^2.0.0-rc.0",
"@solidjs/signals": "^2.0.0-rc.6",
"@solidjs/testing-library": "^0.8.10",
"@solidjs/vite-plugin": "^3.0.0-next.27",
"@solidjs/web": "^2.0.0-rc.3",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/solid-query": "workspace:*",
"babel-preset-solid": "^2.0.0-rc.2",
"npm-run-all2": "^5.0.0",
"solid-js": "^2.0.0-rc.3",
"solid-js": "^2.0.0-rc.6",
"tsup-preset-solid": "^2.2.0"
},
"peerDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions packages/solid-query-persist-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@
"@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.3",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/query-test-utils": "workspace:*",
"@tanstack/solid-query": "workspace:*",
"babel-preset-solid": "^2.0.0-rc.2",
"npm-run-all2": "^5.0.0",
"solid-js": "^2.0.0-rc.4",
"solid-js": "^2.0.0-rc.6",
"tsup-preset-solid": "^2.2.0"
},
"peerDependencies": {
Expand Down
7 changes: 4 additions & 3 deletions packages/solid-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,15 @@
"@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.3",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/query-test-utils": "workspace:*",
"babel-preset-solid": "^2.0.0-rc.2",
"npm-run-all2": "^5.0.0",
"solid-js": "^2.0.0-rc.4",
"solid-js": "^2.0.0-rc.6",
"tsup-preset-solid": "^2.2.0"
},
"peerDependencies": {
"solid-js": ">=2.0.0-rc.4 <3.0.0"
"@solidjs/web": ">=2.0.0-rc.6 <3.0.0",
"solid-js": ">=2.0.0-rc.6 <3.0.0"
}
}
67 changes: 63 additions & 4 deletions packages/solid-query/src/QueryClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createContext, onCleanup, sharedConfig, useContext } from 'solid-js'
import type { Query } from '@tanstack/query-core'
import { hydrate } from '@tanstack/query-core'
import { subscribeFlightData } from '@solidjs/web/server-functions'
import type { DehydratedState, Query } from '@tanstack/query-core'
import type { QueryClient } from './QueryClient'
import type { JSX } from '@solidjs/web'

Expand All @@ -13,6 +15,29 @@ const isServer = typeof window === 'undefined'
*/
export const HYDRATION_KEY_PREFIX = 'sq:'

/**
* The query cache's single-flight source id. Mutation responses can fold
* fresh data for multiple caches at once (Solid's multi-source
* single-flight protocol); this is the slice the query cache claims — the
* provider subscribes its consumer under it, and server collectors
* register under it to produce the data:
*
* ```ts
* import { registerFlightDataSource } from '@solidjs/web/server-functions/server'
* import { FLIGHT_DATA_SOURCE, dehydrate } from '@tanstack/solid-query'
*
* registerFlightDataSource(FLIGHT_DATA_SOURCE, async (event, outcome) => {
* // rebuild the data for outcome.targetUrl into a QueryClient, then
* return dehydrate(queryClient)
* })
* ```
*
* The slice's payload is a `DehydratedState`; the provider consumes it
* with `hydrate()`, so every mounted query on those keys updates before
* the mutation's promise resolves — no follow-up refetches.
*/
export const FLIGHT_DATA_SOURCE = 'sq'

export const QueryClientContext = createContext<(() => QueryClient) | null>(
null,
)
Expand Down Expand Up @@ -64,9 +89,18 @@ function serializeCacheOnServer(client: QueryClient): void {
if (!ctx || !ctx.async || ctx.noHydrate) return

const cache = client.getQueryCache()
// The standard dehydrate filter gates the wire here too, so apps keep
// sensitive or oversized queries out of the HTML with the same option
// they'd pass any other transport.
const shouldDehydrateQuery =
client.getDefaultOptions().dehydrate?.shouldDehydrateQuery
const seen = new Set<string>()
const serializeQuery = (query: Query<any, any, any, any>) => {
if (seen.has(query.queryHash)) return
// Consulted per cache event until it passes, so a filter that rejects
// pending queries (e.g. the core default) still admits the settled
// value if it lands while the request's serialization context is live.
if (shouldDehydrateQuery && !shouldDehydrateQuery(query)) return
const state = query.state
if (state.status === 'success') {
seen.add(query.queryHash)
Expand Down Expand Up @@ -97,15 +131,40 @@ function serializeCacheOnServer(client: QueryClient): void {
/**
* 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`.
* the cache from their hash-keyed registry entries themselves (see
* `useBaseQuery`) and the provider subscribes the cache's single-flight
* consumer: mutation responses carrying a `FLIGHT_DATA_SOURCE` slice (a
* `DehydratedState` produced by a server collector registered under the
* same id) hydrate this client before the mutation's promise resolves.
* Subscribing is inert when no server collector exists — the server just
* folds nothing — so it is unconditional. One consumer per source: with
* nested providers, the innermost mounted one owns the slice.
*/
export const QueryClientProvider = (
props: QueryClientProviderProps,
): JSX.Element => {
props.client.mount()
onCleanup(() => props.client.unmount())
if (isServer) serializeCacheOnServer(props.client)
if (isServer) {
serializeCacheOnServer(props.client)
// Render disposal ends the request: abort what's still in flight and
// drop the cache so user-configured finite gcTime timers can't pin
// the per-request client (and whatever its queries closed over) until
// they fire. Serialized promises are already in seroval's hands, so
// clearing here can't affect the streamed payload.
onCleanup(() => {
props.client.cancelQueries().catch(() => undefined)
props.client.clear()
})
} else {
// Client-only: the server's consumer registry is module state shared
// across requests — registering there would leak between them.
onCleanup(
subscribeFlightData<DehydratedState>(FLIGHT_DATA_SOURCE, (data) => {
hydrate(props.client, data)
}),
)
}

return (
<QueryClientContext value={() => props.client}>
Expand Down
79 changes: 79 additions & 0 deletions packages/solid-query/src/__tests__/dehydrateSettled.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '../QueryClient'
import { dehydrateSettled } from '../dehydrateSettled'

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

describe('dehydrateSettled', () => {
it('waits for in-flight fetches instead of snapshotting mid-fetch', async () => {
const client = new QueryClient()
// Fire-and-forget, the way loaders hint prefetches.
void client.prefetchQuery({
queryKey: ['slow'],
queryFn: async () => {
await sleep(20)
return 'slow-data'
},
})

const state = await dehydrateSettled(client)

const query = state.queries.find((q) => q.queryHash === '["slow"]')
expect(query?.state.data).toBe('slow-data')
expect(query?.state.status).toBe('success')
})

it('chases fetches dispatched by earlier settlements to quiescence', async () => {
const client = new QueryClient()
void client.prefetchQuery({
queryKey: ['first'],
queryFn: async () => {
await sleep(10)
// A dependent fetch that only exists once the first one lands.
void client.prefetchQuery({
queryKey: ['second'],
queryFn: async () => {
await sleep(10)
return 'second-data'
},
})
return 'first-data'
},
})

const state = await dehydrateSettled(client)

expect(state.queries.map((q) => q.queryHash).sort()).toEqual([
'["first"]',
'["second"]',
])
expect(
state.queries.find((q) => q.queryHash === '["second"]')?.state.data,
).toBe('second-data')
})

it('settles failures without rejecting and forwards dehydrate options', async () => {
const client = new QueryClient()
void client.prefetchQuery({
queryKey: ['ok'],
queryFn: async () => {
await sleep(5)
return 'ok-data'
},
})
void client.prefetchQuery({
queryKey: ['boom'],
retry: false,
queryFn: async () => {
await sleep(5)
throw new Error('nope')
},
})

const state = await dehydrateSettled(client, {
shouldDehydrateQuery: (query) => query.state.status === 'success',
})

expect(state.queries.map((q) => q.queryHash)).toEqual(['["ok"]'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,29 @@
import { renderToStream } from '@solidjs/web'
import { QueryClient } from '@tanstack/solid-query'
import { StreamApp } from './StreamApp'
import type { Query } from '@tanstack/solid-query'
import type { StreamCounts } from './StreamApp'

const client = new QueryClient()
const counts: StreamCounts = { header: 0, feed: 0 }

// The provider clears the cache when the render disposes (the SSR
// teardown), so query states are recorded live off cache events rather
// than read back after completion.
const snapshots = new Map<
string,
{ queryKey: unknown; queryHash: string; state: unknown }
>()
client.getQueryCache().subscribe((event) => {
if (event.type === 'removed') return
const query: Query<any, any, any, any> = event.query
snapshots.set(query.queryHash, {
queryKey: query.queryKey,
queryHash: query.queryHash,
state: query.state,
})
})

const start = Date.now()
const chunks: Array<{ t: number; payload: string }> = []

Expand All @@ -28,13 +46,13 @@ await new Promise<void>((resolve) => {
})
})

const queries = client
.getQueryCache()
.getAll()
.map((query) => ({
queryKey: query.queryKey,
queryHash: query.queryHash,
state: query.state,
}))
const cacheEmptyAfterDispose = client.getQueryCache().getAll().length === 0

console.log(JSON.stringify({ chunks, counts, queries }))
console.log(
JSON.stringify({
chunks,
counts,
queries: [...snapshots.values()],
cacheEmptyAfterDispose,
}),
)
Loading
Loading