From e8741346fb5c17fbeef541041b098dd5e39544ff Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Fri, 14 Aug 2026 05:20:59 +0500 Subject: [PATCH] fix(solid-query): attach 'useQueries' through the provider's hydration channel --- .../solid-usequeries-hydration-channel.md | 5 ++ .../solid-query/src/QueryClientProvider.tsx | 24 +++++++-- .../fixtures/hydration/StreamApp.tsx | 27 +++++++++- .../fixtures/hydration/entry-client.tsx | 2 +- .../hydration/entry-server-stream.tsx | 2 +- .../src/__tests__/hydration-utils.ts | 4 +- .../src/__tests__/hydration.test.tsx | 35 ++++++++++++- packages/solid-query/src/useQueries.ts | 51 ++++++++++++++----- 8 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 .changeset/solid-usequeries-hydration-channel.md diff --git a/.changeset/solid-usequeries-hydration-channel.md b/.changeset/solid-usequeries-hydration-channel.md new file mode 100644 index 0000000000..f09269125c --- /dev/null +++ b/.changeset/solid-usequeries-hydration-channel.md @@ -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. diff --git a/packages/solid-query/src/QueryClientProvider.tsx b/packages/solid-query/src/QueryClientProvider.tsx index 9f2beb95e4..5298182469 100644 --- a/packages/solid-query/src/QueryClientProvider.tsx +++ b/packages/solid-query/src/QueryClientProvider.tsx @@ -69,10 +69,21 @@ 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( - () => (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(() => { + replayProbe.executorRan = true + }) + return undefined + }, ) const coordinator = isServer ? null @@ -80,8 +91,13 @@ export const QueryClientProvider = ( 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 }) } }, ) diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/StreamApp.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/StreamApp.tsx index 9835dee499..9bc4c89e34 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/StreamApp.tsx +++ b/packages/solid-query/src/__tests__/fixtures/hydration/StreamApp.tsx @@ -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 { @@ -50,10 +55,30 @@ function FeedQuery(props: StreamAppProps) { return {query.data} } +// 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 {queries[0].data} +} + export function StreamApp(props: StreamAppProps) { return (
+ loading-header
}> 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..616b784c54 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx +++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx @@ -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, diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server-stream.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server-stream.tsx index 8dc8d005f7..6faea1382c 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server-stream.tsx +++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server-stream.tsx @@ -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 }> = [] diff --git a/packages/solid-query/src/__tests__/hydration-utils.ts b/packages/solid-query/src/__tests__/hydration-utils.ts index 12d83b8341..ecba18bf7d 100644 --- a/packages/solid-query/src/__tests__/hydration-utils.ts +++ b/packages/solid-query/src/__tests__/hydration-utils.ts @@ -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 } } @@ -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 } } diff --git a/packages/solid-query/src/__tests__/hydration.test.tsx b/packages/solid-query/src/__tests__/hydration.test.tsx index 74581ad130..76fbd0230e 100644 --- a/packages/solid-query/src/__tests__/hydration.test.tsx +++ b/packages/solid-query/src/__tests__/hydration.test.tsx @@ -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 — @@ -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') diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index a17455cbf8..08415cf4b6 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -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' @@ -241,20 +243,44 @@ 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, + // 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, - // 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() }) } }, @@ -262,6 +288,7 @@ export function useQueries< ) onCleanup(() => { + disposed = true unsubscribe() })