diff --git a/.changeset/solid-disabled-query-ssr.md b/.changeset/solid-disabled-query-ssr.md new file mode 100644 index 00000000000..515f1fe1d05 --- /dev/null +++ b/.changeset/solid-disabled-query-ssr.md @@ -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 `` 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. diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/build-and-render.mjs b/packages/solid-query/src/__tests__/fixtures/hydration/build-and-render.mjs index 066b023f886..101c089283e 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/build-and-render.mjs +++ b/packages/solid-query/src/__tests__/fixtures/hydration/build-and-render.mjs @@ -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', @@ -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) diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server-disabled.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server-disabled.tsx new file mode 100644 index 00000000000..fd04983f225 --- /dev/null +++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server-disabled.tsx @@ -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 ( +
+ {String(query.data)}|{query.status}|{String(query.isEnabled)} +
+ ) +} + +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(() => ( + + + + )).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) diff --git a/packages/solid-query/src/__tests__/hydration-utils.ts b/packages/solid-query/src/__tests__/hydration-utils.ts index 648a5f628bf..8c0525372a4 100644 --- a/packages/solid-query/src/__tests__/hydration-utils.ts +++ b/packages/solid-query/src/__tests__/hydration-utils.ts @@ -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' @@ -50,6 +51,14 @@ export interface ServerReport { queries: Array 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 { diff --git a/packages/solid-query/src/__tests__/hydration.test.tsx b/packages/solid-query/src/__tests__/hydration.test.tsx index c4271ed7bfc..60d28d582b0 100644 --- a/packages/solid-query/src/__tests__/hydration.test.tsx +++ b/packages/solid-query/src/__tests__/hydration.test.tsx @@ -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 = /
]*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 diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index d152a685349..be7720c56df 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -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 }