diff --git a/.changeset/disabled-query-server-read.md b/.changeset/disabled-query-server-read.md new file mode 100644 index 0000000000..64b1ca8f2a --- /dev/null +++ b/.changeset/disabled-query-server-read.md @@ -0,0 +1,12 @@ +--- +'@tanstack/solid-query': patch +--- + +fix: commit the idle read of a disabled query on the server. A query +disabled with nothing cached parked its reader on a promise that never +settles, which is the intended client behavior — an enabling change, a +refetch or a cache write revives it. The server has no later: the render +has to finish, and nothing will enable the query or write the cache +before it does, so the render stalled forever and emitted nothing at all. +The server now commits the idle read, which is the settled SSR truth for +a disabled query and the same contract its meta channel already honors. diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx index 58fcc6eba9..bab3ea67c8 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx +++ b/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx @@ -21,6 +21,7 @@ export interface FetchCounts { stale: number placeholder: number prefetched: number + disabled: number } export interface AppProps { @@ -100,6 +101,29 @@ function Queries(props: AppProps) { ) } +/** Reads the data of a query that is disabled with nothing cached. On the + * server that read has nothing to wait on and no later in which to get one — + * the render has to finish, so the idle read is its settled SSR truth and the + * document is emitted. (On the client the same read parks the reader in this + * boundary until something starts the query, which is why it lives in a + * boundary of its own here: the rest of the app hydrates around it.) */ +function DisabledConsumer(props: AppProps) { + const disabled = useQuery(() => ({ + queryKey: ['disabled'], + queryFn: async () => { + props.counts.disabled++ + await sleep(5) + return `disabled-${props.source}` + }, + enabled: false, + })) + return ( + + {String(disabled.data)}|{disabled.status}|{String(disabled.isEnabled)} + + ) +} + /** Never rendered on the server — mounted by tests after hydration. Its * query was prefetched (and only prefetched) during SSR; the hash-keyed * registry entry must satisfy it with zero client fetches. */ @@ -139,6 +163,9 @@ export function App(props: AppProps) { + loading}> + + ) } 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 9a67bea7b7..c07deb6515 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx +++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-client.tsx @@ -19,6 +19,7 @@ export function createApp() { stale: 0, placeholder: 0, prefetched: 0, + disabled: 0, } const [lateMount, setLateMount] = createSignal(false) return { diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx index e96396c264..dfe922cecd 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx +++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx @@ -15,6 +15,7 @@ const counts: FetchCounts = { stale: 0, placeholder: 0, prefetched: 0, + disabled: 0, } // Fully-settled single-string render. Collected through pipe() rather than diff --git a/packages/solid-query/src/__tests__/hydration-utils.ts b/packages/solid-query/src/__tests__/hydration-utils.ts index 9b53daad17..a72bd1761a 100644 --- a/packages/solid-query/src/__tests__/hydration-utils.ts +++ b/packages/solid-query/src/__tests__/hydration-utils.ts @@ -36,6 +36,7 @@ export interface ServerReport { stale: number placeholder: number prefetched: number + disabled: number } queries: Array } @@ -54,6 +55,7 @@ export interface ClientBundle { stale: number placeholder: number prefetched: number + disabled: number } showLate: () => void 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 f62c04d55d..390cbdc35f 100644 --- a/packages/solid-query/src/__tests__/hydration.test.tsx +++ b/packages/solid-query/src/__tests__/hydration.test.tsx @@ -44,6 +44,7 @@ describe('SSR hydration', () => { stale: 1, placeholder: 0, prefetched: 1, + disabled: 0, }) expect(string.html).toContain('fresh-server') expect(string.html).toContain('stale-server') @@ -97,6 +98,18 @@ describe('SSR hydration', () => { expect(ph.replace(//g, '')).toBe( 'placeholder-value|true|success', ) + + // A disabled query with nothing cached has no fetch to wait for, so its + // read settles as the idle state (data|status|isEnabled) and the render + // completes. Parking it on a never-settling promise instead deadlocks + // the whole render — the server has no later in which the query could + // become enabled, so this render would emit nothing at all. + const disabled = /]*id="disabled"[^>]*>(.*?)<\/span>/.exec( + string.html, + )![1]! + expect(disabled.replace(//g, '')).toBe( + 'undefined|pending|false', + ) }) it('hydration primes the query cache and refetches only per staleness rules', async () => { @@ -165,6 +178,14 @@ describe('SSR hydration', () => { ) }) + // The disabled query hydrates to the identical idle face the server + // serialized, and stays disabled: no priming (it has no data to + // transfer) and no fetch, on mount or after the window closes. + expect(container.querySelector('#disabled')?.textContent).toBe( + 'undefined|pending|false', + ) + expect(app.counts.disabled).toBe(0) + // The serialized observer results no longer carry a hydrationData // copy at all — the node payload is the only transport. const registry = (globalThis as any)._$HY.r as Record diff --git a/packages/solid-query/src/types.ts b/packages/solid-query/src/types.ts index 927af03e54..b2003bb984 100644 --- a/packages/solid-query/src/types.ts +++ b/packages/solid-query/src/types.ts @@ -76,6 +76,10 @@ export type UseQueryOptions< * (committed, placeholder, initial), or throws (`` / * `throwOnError`). The v5 `TData | undefined` face existed because reads * could observe the pre-fetch gap; here that gap is suspension. + * + * The server is the one exception: suspending needs a later, and a render + * that has to finish has none for a query nothing will ever enable. A + * disabled read commits `undefined` there rather than stalling the render. */ export type UseBaseQueryResult< TData = unknown, diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index d152a68534..5d032edc2d 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -531,6 +531,19 @@ export function useBaseQueryLayer< if (!isServer) observer.setOptions(opts as any) return chainOnce(q.fetch(opts as any), select, wrap) } + /** + * Disabled, so there is nothing to pull and nothing in flight. Parking + * the reader (see `NEVER`) is right on the client — an enabling change, + * a refetch or a cache write revives it later. The server has no later: + * the render must finish, and nothing will enable the query or write + * the cache before it does, so parking there stalls the render forever + * and emits nothing at all. The idle read IS the settled SSR truth — + * the same contract the meta channel already honors ('pending' serves + * as-is for a disabled query) — so commit it and let the client hydrate + * the identical state. Committed directly rather than through `wrap`: + * `select` must not run on an absent value. + */ + if (isServer) return { value: undefined as TData } return NEVER }