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
14 changes: 14 additions & 0 deletions .changeset/solid-disabled-query-ssr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@tanstack/solid-query': patch
---

fix: finish server renders that read a disabled query. Reading `.data` from a
`useQuery` with `enabled: false` and nothing cached stopped an SSR render from
ever completing — no bytes at all, since the data node was handed a promise
that can never settle. That parking is intended client behaviour (the reader
suspends into the nearest `<Loading>` until an enable, refetch or cache write
revives the compute), but on the server there is no later: the render has to
finish, and nothing will enable the query or write the cache before it does.
A disabled query with no data now commits its idle state on the server, which
is the contract the scalar metadata channel already honoured and the state the
client hydrates to. Client behaviour is unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ const alias = {

// Server bundles: everything inlined so module resolution inside the temp
// output dir is a non-issue.
for (const entry of ['entry-server', 'entry-server-stream']) {
for (const entry of [
'entry-server',
'entry-server-stream',
'entry-server-disabled',
]) {
await build({
configFile: false,
logLevel: 'error',
Expand Down Expand Up @@ -82,10 +86,16 @@ const streamReport = execFileSync(
[path.join(outDir, 'entry-server-stream.mjs')],
{ encoding: 'utf-8' },
)
const disabledReport = execFileSync(
process.execPath,
[path.join(outDir, 'entry-server-disabled.mjs')],
{ encoding: 'utf-8' },
)

// Sanity-check both parse before handing them to the test.
// Sanity-check all three parse before handing them to the test.
const combined = JSON.stringify({
string: JSON.parse(report),
stream: JSON.parse(streamReport),
disabled: JSON.parse(disabledReport),
})
process.stdout.write(combined)
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* SSR entry for the disabled-query regression test.
*
* A transcription of the reproduction from the issue: no boundary, one
* provider, one disabled query whose `.data` is read during render. A
* disabled query has nothing in flight and nothing cached, so the read has
* nothing to wait on and the stream has to finish. The absence of a
* boundary is the point — a regression must not be able to hide by parking
* inside one.
*
* Reports `finished: false` on a timeout instead of hanging, so a
* regression surfaces as an assertion rather than an unsettled top-level
* await.
*/
import { renderToStream } from '@solidjs/web'
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/solid-query'

const RENDER_TIMEOUT = 8000

let fetches = 0

function Disabled() {
const query = useQuery(() => ({
queryKey: ['disabled'],
queryFn: () => {
fetches++
return Promise.resolve('data')
},
enabled: false,
}))
return (
<div id="out">
{String(query.data)}|{query.status}|{String(query.isEnabled)}
</div>
)
}

const client = new QueryClient()

const result = await new Promise<{ finished: boolean; html: string }>(
(resolve) => {
let html = ''
const timer = setTimeout(
() => resolve({ finished: false, html }),
RENDER_TIMEOUT,
)
renderToStream(() => (
<QueryClientProvider client={client}>
<Disabled />
</QueryClientProvider>
)).pipe({
write(payload: string) {
html += payload
},
end() {
clearTimeout(timer)
resolve({ finished: true, html })
},
})
},
)

console.log(JSON.stringify({ ...result, fetches }))
// A parked render leaves handles open; exit rather than wait them out.
process.exit(0)
13 changes: 11 additions & 2 deletions packages/solid-query/src/__tests__/hydration-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
*
* The fixture app in `fixtures/hydration/` is built with vite in a plain node
* subprocess (vite/esbuild cannot run inside the jsdom worker): a server
* bundle, a streaming server bundle, and a hydratable client bundle. The
* subprocess also executes both server entries and returns their reports.
* bundle, a streaming server bundle, a boundary-less disabled-query bundle,
* and a hydratable client bundle. The subprocess also executes every server
* entry and returns their reports.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync } from 'node:fs'
Expand Down Expand Up @@ -50,6 +51,14 @@ export interface ServerReport {
queries: Array<QuerySnapshot>
cacheEmptyAfterDispose: boolean
}
/** Boundary-less render of a single disabled query — see
* `fixtures/hydration/entry-server-disabled.tsx`. */
disabled: {
/** False if the render timed out instead of completing. */
finished: boolean
html: string
fetches: number
}
}

export interface ClientBundle {
Expand Down
15 changes: 15 additions & 0 deletions packages/solid-query/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ describe('SSR hydration', () => {
)
})

it('finishes a render that reads a disabled query', () => {
// A disabled query has nothing in flight, nothing cached, and nothing
// that can enable it or write the cache before the render finishes, so
// a server read of it has nothing to wait on. Its idle state is the
// settled SSR truth: the render must complete and serialize that,
// rather than park the reader the way the client does (where a later
// enable, refetch or cache write revives the compute). Rendered
// without a boundary, so parking cannot hide as a fallback.
const { disabled } = harness.report
expect(disabled.finished).toBe(true)
expect(disabled.fetches).toBe(0)
const out = /<div [^>]*id="out"[^>]*>(.*?)<\/div>/.exec(disabled.html)![1]!
expect(out.replace(/<!--[^>]*-->/g, '')).toBe('undefined|pending|false')
})

it('clears the per-request cache when the render disposes', () => {
// The provider's dispose-time teardown (cancel + clear) must leave
// nothing behind: user-configured finite gcTime schedules timers on
Expand Down
12 changes: 12 additions & 0 deletions packages/solid-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,18 @@ export function useBaseQueryLayer<
if (!isServer) observer.setOptions(opts as any)
return chainOnce(q.fetch(opts as any), select, wrap)
}
/**
* Disabled, with nothing cached. The three guards above are all
* unreachable on the server, but this one is not, and parking here has
* no server meaning: the render has to finish, and nothing will enable
* the query or write the cache before it does. So commit the idle
* value — 'pending' with no data IS a disabled query's settled SSR
* truth, which is the contract `serverMeta` already honors by not
* tying its read to this node while disabled, and it is the state the
* client hydrates to. Committed rather than passed through `wrap`:
* `select` must not be invoked on absent data.
*/
if (isServer) return { value: undefined as TData }
return NEVER
}

Expand Down