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
12 changes: 12 additions & 0 deletions .changeset/disabled-query-server-read.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions packages/solid-query/src/__tests__/fixtures/hydration/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface FetchCounts {
stale: number
placeholder: number
prefetched: number
disabled: number
}

export interface AppProps {
Expand Down Expand Up @@ -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 (
<span id="disabled">
{String(disabled.data)}|{disabled.status}|{String(disabled.isEnabled)}
</span>
)
}

/** 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. */
Expand Down Expand Up @@ -139,6 +163,9 @@ export function App(props: AppProps) {
<LateConsumer {...props} />
</Show>
</Loading>
<Loading fallback={<div>loading</div>}>
<DisabledConsumer {...props} />
</Loading>
</QueryClientProvider>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function createApp() {
stale: 0,
placeholder: 0,
prefetched: 0,
disabled: 0,
}
const [lateMount, setLateMount] = createSignal(false)
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/solid-query/src/__tests__/hydration-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ServerReport {
stale: number
placeholder: number
prefetched: number
disabled: number
}
queries: Array<QuerySnapshot>
}
Expand All @@ -54,6 +55,7 @@ export interface ClientBundle {
stale: number
placeholder: number
prefetched: number
disabled: number
}
showLate: () => void
mount: (container: HTMLElement) => () => void
Expand Down
21 changes: 21 additions & 0 deletions packages/solid-query/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 = /<span[^>]*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 () => {
Expand Down Expand Up @@ -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<string, any>
Expand Down
4 changes: 4 additions & 0 deletions packages/solid-query/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export type UseQueryOptions<
* (committed, placeholder, initial), or throws (`<Errored>` /
* `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,
Expand Down
13 changes: 13 additions & 0 deletions packages/solid-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down