Skip to content
Open
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/solid-usequeries-hydration-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-query': patch
---

fix: attach `useQueries` through the provider's hydration channel, so a hydrated `useQueries` waits for its entries to be primed instead of refetching data that is still streaming in from the server.
24 changes: 20 additions & 4 deletions packages/solid-query/src/QueryClientProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,35 @@ export const QueryClientProvider = (
// 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.
// Client, fresh mount: the compute runs for real and returns undefined,
// so the channel is closed immediately and nothing waits on it.
const replayProbe = { executorRan: false }
const [channelValue] = createSignal<DehydrationChannelYield | undefined>(
() => (isServer ? createServerDehydrationChannel(props.client) : undefined),
() => {
if (isServer) return createServerDehydrationChannel(props.client)
// Replay detection, as in useBaseQuery: a real Promise runs its
// executor synchronously, the hydration mock does not, so the
// executor running means this compute was not replayed from a
// serialized channel.
void new Promise<void>(() => {
replayProbe.executorRan = true
})
return undefined
},
)
const coordinator = isServer
? null
: createHydrationCoordinator(() => props.client)
createRenderEffect(
() => (isServer ? undefined : channelValue()),
(value) => {
if (value && coordinator) {
if (!coordinator) return
if (value) {
coordinator.applyYield(value)
} else if (replayProbe.executorRan) {
// Fresh client mount: no channel was serialized, so no entry will
// ever be primed and consumers must not wait for one.
coordinator.applyYield({ entries: [], done: true })
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@
* attach.
*/
import { Loading } from 'solid-js'
import { QueryClientProvider, useQuery } from '@tanstack/solid-query'
import {
QueryClientProvider,
useQueries,
useQuery,
} from '@tanstack/solid-query'
import type { QueryClient } from '@tanstack/solid-query'

export interface StreamCounts {
header: number
feed: number
tags: number
}

export interface StreamAppProps {
Expand Down Expand Up @@ -50,10 +55,30 @@ function FeedQuery(props: StreamAppProps) {
return <span id="feed">{query.data}</span>
}

// Lives in the shell, so it hydrates with the first flush — but it settles
// after the feed, so its entry only reaches the client with the last one.
function TagsQueries(props: StreamAppProps) {
const queries = useQueries(() => ({
queries: [
{
queryKey: ['tags'],
queryFn: async () => {
props.counts.tags++
await sleep(300)
return `tags-${props.source}`
},
staleTime: 60_000,
},
],
}))
return <span id="tags">{queries[0].data}</span>
}

export function StreamApp(props: StreamAppProps) {
return (
<QueryClientProvider client={props.client}>
<div>
<TagsQueries {...props} />
<Loading fallback={<div>loading-header</div>}>
<HeaderQuery {...props} />
</Loading>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function createApp() {

export function createStreamApp() {
const queryClient = new QueryClient()
const counts: StreamCounts = { header: 0, feed: 0 }
const counts: StreamCounts = { header: 0, feed: 0, tags: 0 }
return {
queryClient,
counts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { StreamApp } from './StreamApp'
import type { StreamCounts } from './StreamApp'

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

const start = Date.now()
const chunks: Array<{ t: number; payload: string }> = []
Expand Down
4 changes: 2 additions & 2 deletions packages/solid-query/src/__tests__/hydration-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface ServerReport {
}
stream: {
chunks: Array<{ t: number; payload: string }>
counts: { header: number; feed: number }
counts: { header: number; feed: number; tags: number }
queries: Array<QuerySnapshot>
}
}
Expand All @@ -49,7 +49,7 @@ export interface ClientBundle {
}
createStreamApp: () => {
queryClient: QueryClient
counts: { header: number; feed: number }
counts: { header: number; feed: number; tags: number }
mount: (container: HTMLElement) => () => void
}
}
Expand Down
35 changes: 34 additions & 1 deletion packages/solid-query/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,39 @@ describe('streaming SSR hydration', () => {
}
})

it('holds a hydrated useQueries back until its entries are primed', async () => {
// The tags query lives in the shell, so it hydrates with the first
// flush, but it settles last on the server, so its entry only arrives
// with the final one. Its observer must wait for that entry instead of
// applying mount semantics to a cache that is still being primed.
const { phase1, phase2 } = splitStream()
const app = bundle.createStreamApp()
const container = document.createElement('div')
document.body.appendChild(container)
bootstrapHydrationGlobals()

applyChunks(container, phase1)
const dispose = app.mount(container)

try {
await microtasks()
expect(app.queryClient.getQueryState(['tags'])?.data).toBeUndefined()
expect(app.counts.tags).toBe(0)

applyChunks(container, phase2)
await vi.waitFor(() => {
expect(container.querySelector('#tags')?.textContent).toBe(
'tags-server',
)
})
expect(app.counts.tags).toBe(0)
await tick(30)
} finally {
dispose()
container.remove()
}
})

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 —
Expand Down Expand Up @@ -349,7 +382,7 @@ describe('streaming SSR hydration', () => {
?.getObserversCount(),
).toBe(1)
})
expect(app.counts).toEqual({ header: 0, feed: 0 })
expect(app.counts).toEqual({ header: 0, feed: 0, tags: 0 })

// And the late-hydrated components are live.
app.queryClient.setQueryData(['feed'], 'updated-client')
Expand Down
51 changes: 39 additions & 12 deletions packages/solid-query/src/useQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
reconcile,
runWithOwner,
untrack,
useContext,
} from 'solid-js'
import { useQueryClient } from './QueryClientProvider'
import { HydrationCoordinatorContext } from './hydrationChannel'
import { useIsRestoring } from './isRestoring'
import type { QueryOptions, UseQueryResult } from './types'
import type { Accessor } from 'solid-js'
Expand Down Expand Up @@ -241,27 +243,52 @@ export function useQueries<
// When isRestoring is true (persist client is restoring), we defer
// subscription until restoring completes.
let unsubscribe: () => void = noop
let disposed = false
const coordinator = useContext(HydrationCoordinatorContext)

const subscribe = () => {
if (disposed) return
unsubscribe = observer.subscribe((result) => {
runWithOwner(null, () => {
setState(
reconcile(
[...result] as Array<QueryObserverResult>,
// Use a key function that returns undefined so reconcile
// uses positional matching and recursively updates nested properties
() => undefined,
),
)
})
})
}

// With a provider, attach once every query's entry has been primed from
// its dehydration channel — or once the channel completes without them.
// A single QueriesObserver covers all of the queries, so it can only
// attach when the last one is ready; attaching earlier applies mount
// semantics to a cache that is still being primed and refetches data that
// is already in flight from the SSR stream. On a fresh client mount the
// provider closes the channel right away, so nothing waits.
createEffect(
() => {
if (!isRestoring()) {
unsubscribe = observer.subscribe((result) => {
runWithOwner(null, () => {
setState(
reconcile(
[...result] as Array<QueryObserverResult>,
// Use a key function that returns undefined so reconcile
// uses positional matching and recursively updates nested properties
() => undefined,
),
)
})
if (isRestoring()) return
const queries = defaultedQueries()
if (!coordinator || queries.length === 0) {
subscribe()
return
}
let pending = queries.length
for (const options of queries) {
coordinator.whenQueryPrimed(options.queryHash, () => {
if (--pending === 0) subscribe()
})
}
},
() => {},
)

onCleanup(() => {
disposed = true
unsubscribe()
})

Expand Down