Skip to content
Draft
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/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).
78 changes: 74 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,40 @@ 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'

// The named-source overload of subscribeFlightData ships in the
// @solidjs/web release after 2.0.0-rc.4 (solidjs/solid#653dd41e); this
// cast bridges the installed types until the peer range bumps.
const subscribeFlightSource = subscribeFlightData as unknown as (
source: string,
consumer: (
data: DehydratedState,
context: { response: Response },
) => void | Promise<void>,
) => () => void

export const QueryClientContext = createContext<(() => QueryClient) | null>(
null,
)
Expand Down Expand Up @@ -64,9 +100,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 +142,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(
subscribeFlightSource(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,
}),
)
100 changes: 76 additions & 24 deletions packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,92 @@
import { renderToStream } from '@solidjs/web'
import { QueryClient } from '@tanstack/solid-query'
import { App } from './App'
import type { Query } from '@tanstack/solid-query'
import type { FetchCounts } from './App'

interface QuerySnapshot {
queryKey: unknown
queryHash: string
state: unknown
}

/**
* 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. States are immutable objects in
* query-core; keeping the latest per hash is an accurate snapshot.
*/
function trackSnapshots(client: QueryClient): Map<string, QuerySnapshot> {
const snapshots = new Map<string, QuerySnapshot>()
const record = (query: Query<any, any, any, any>) => {
snapshots.set(query.queryHash, {
queryKey: query.queryKey,
queryHash: query.queryHash,
state: query.state,
})
}
client.getQueryCache().subscribe((event) => {
if (event.type !== 'removed') record(event.query)
})
return snapshots
}

function renderApp(client: QueryClient, counts: FetchCounts): Promise<string> {
return new Promise<string>((resolve) => {
let out = ''
// Collected through pipe() rather than the thenable form so the fixture
// builds against any solid-js 2 beta (renderToStringAsync was removed
// after beta.29).
renderToStream(() => (
<App client={client} source="server" counts={counts} />
)).pipe({
write(payload: string) {
out += payload
},
end() {
resolve(out)
},
})
})
}

const client = new QueryClient()
const counts: FetchCounts = {
fresh: 0,
stale: 0,
placeholder: 0,
prefetched: 0,
}
const snapshots = trackSnapshots(client)
const html = await renderApp(client, counts)

// Fully-settled single-string render. Collected through pipe() rather than
// the thenable form so the fixture builds against any solid-js 2 beta
// (renderToStringAsync was removed after beta.29).
const html = await new Promise<string>((resolve) => {
let out = ''
renderToStream(() => (
<App client={client} source="server" counts={counts} />
)).pipe({
write(payload: string) {
out += payload
},
end() {
resolve(out)
// The dispose-time teardown must have emptied the per-request cache.
const cacheEmptyAfterDispose = client.getQueryCache().getAll().length === 0

// Second pass: same app on a client whose standard dehydrate filter
// excludes the stale query — its registry entry must stay off the wire
// while the others still ship.
const filteredClient = new QueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: (query) => query.queryKey[0] !== 'stale',
},
})
},
})
const filteredCounts: FetchCounts = {
fresh: 0,
stale: 0,
placeholder: 0,
prefetched: 0,
}
const filteredHtml = await renderApp(filteredClient, filteredCounts)

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

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