diff --git a/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx b/packages/solid-query/src/__tests__/fixtures/hydration/App.tsx index 58fcc6eba99..b51758f08f3 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 { @@ -80,10 +81,25 @@ function Queries(props: AppProps) { staleTime: 60_000, })) + // Disabled with nothing cached: reads `undefined` instead of suspending, + // otherwise the boundary would hold the stream open forever. + const disabled = useQuery(() => ({ + queryKey: ['disabled'], + queryFn: async () => { + props.counts.disabled++ + await sleep(5) + return `disabled-${props.source}` + }, + enabled: false, + })) + return (
{fresh.data} {stale.data} + + {String(disabled.data)}|{disabled.status}|{disabled.fetchStatus} + {/* Meta guards: boundaries serialize settled state only, so these must show settled values in the server HTML — a transient ('pending', fetching) here is a hydration mismatch in waiting. */} 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 9a67bea7b7f..c07deb65152 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 6a60f1766af..3555adf554d 100644 --- a/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx +++ b/packages/solid-query/src/__tests__/fixtures/hydration/entry-server.tsx @@ -62,6 +62,7 @@ const counts: FetchCounts = { stale: 0, placeholder: 0, prefetched: 0, + disabled: 0, } const snapshots = trackSnapshots(client) const html = await renderApp(client, counts) @@ -84,6 +85,7 @@ const filteredCounts: FetchCounts = { stale: 0, placeholder: 0, prefetched: 0, + disabled: 0, } const filteredHtml = await renderApp(filteredClient, filteredCounts) diff --git a/packages/solid-query/src/__tests__/hydration-utils.ts b/packages/solid-query/src/__tests__/hydration-utils.ts index 648a5f628bf..87ead626999 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 /** Whether the provider's dispose-time teardown emptied the cache. */ @@ -60,6 +61,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 c4271ed7bfc..99f5c92a28c 100644 --- a/packages/solid-query/src/__tests__/hydration.test.tsx +++ b/packages/solid-query/src/__tests__/hydration.test.tsx @@ -44,9 +44,18 @@ describe('SSR hydration', () => { stale: 1, placeholder: 0, prefetched: 1, + disabled: 0, }) expect(string.html).toContain('fresh-server') expect(string.html).toContain('stale-server') + + // Disabled query (data|status|fetchStatus): the render completing is + // the assertion — a suspended read here would hold the stream forever. + const disabled = /]*>(.*?)<\/span>/.exec( + string.html, + )![1]! + expect(disabled.replace(//g, '')).toBe('undefined|pending|idle') + expect(string.html).not.toMatch(/sq:\[\\"disabled\\"\]/) // Cache entries ride Solid's hydration registry content-addressed by // query hash (`sq:` → { data, t }) — `t` (dataUpdatedAt) lets // the hydrating client reconstruct the entry with staleness intact, @@ -180,6 +189,11 @@ describe('SSR hydration', () => { 'fresh-server', ) + expect(app.counts.disabled).toBe(0) + expect(container.querySelector('#disabled')?.textContent).toBe( + 'undefined|pending|idle', + ) + // The placeholder query hydrated showing its placeholder (identical // to the server HTML), then fetched for real once its window closed // (its node had no serialized entry) and swapped in data. diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index d2e6f88559f..46c88b91f35 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -587,12 +587,15 @@ describe('useQuery suspense semantics (Loading/Errored boundaries)', () => { )) await vi.advanceTimersByTimeAsync(10) - // Disabled: nothing fetches and the guard-free data read parks the - // boundary. expect(queryFn).toHaveBeenCalledTimes(0) - expect(rendered.getByText('loading')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByRole('heading').textContent).toBe('') fireEvent.click(rendered.getByRole('button', { name: /fire/i })) + await vi.advanceTimersByTimeAsync(0) + // the committed `undefined` read holds while the fetch is in flight + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByRole('heading').textContent).toBe('') await vi.advanceTimersByTimeAsync(10) expect(rendered.getByRole('heading').textContent).toBe('23') // Exactly one fetch: the pull path syncs observer options before @@ -767,12 +770,13 @@ describe('useQuery suspense semantics (Loading/Errored boundaries)', () => { const rendered = renderWithClient(queryClient, () => ) await vi.advanceTimersByTimeAsync(10) - // Disabled: the guard-free data read parks the boundary — nothing to - // render and nothing in flight. - expect(rendered.getByText('loading')).toBeInTheDocument() + expect(rendered.getByText('rendered')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() // enable -> fetch fails -> throw error, exactly once fireEvent.click(rendered.getByLabelText('fail')) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('rendered')).toBeInTheDocument() // render error boundary fallback (error boundary) await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('error boundary')).toBeInTheDocument() diff --git a/packages/solid-query/src/__tests__/useQueries.test-d.tsx b/packages/solid-query/src/__tests__/useQueries.test-d.tsx index 7e98afb4084..44adf28f431 100644 --- a/packages/solid-query/src/__tests__/useQueries.test-d.tsx +++ b/packages/solid-query/src/__tests__/useQueries.test-d.tsx @@ -190,6 +190,33 @@ describe('useQueries', () => { const queryResults = useCustomQueries() const data = queryResults[0].data + // the forwarded options may carry `enabled` + expectTypeOf(data).toEqualTypeOf() + }) + + it('narrows data when the forwarded options cannot disable the query', () => { + type Data = string + + const useCustomQueries = ( + options?: OmitKeyof< + QueryOptions, + 'queryKey' | 'queryFn' | 'enabled' | 'initialData' + >, + ) => { + return useQueries(() => ({ + queries: [ + { + ...options, + queryKey: queryKey(), + queryFn: () => Promise.resolve('data'), + }, + ], + })) + } + + const queryResults = useCustomQueries() + const data = queryResults[0].data + expectTypeOf(data).toEqualTypeOf() }) }) @@ -228,8 +255,37 @@ describe('useQueries', () => { const firstResult = queryResults[0] - expectTypeOf(firstResult).toEqualTypeOf>() - expectTypeOf(firstResult.data).toEqualTypeOf() + expectTypeOf(firstResult).toEqualTypeOf< + UseQueryResult + >() + expectTypeOf(firstResult.data).toEqualTypeOf() + }) + + it('TData should include undefined when enabled is not literally true', () => { + const queryResults = useQueries(() => ({ + queries: [ + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(5), + enabled: Math.random() > 0.5, + }, + { + queryKey: queryKey(), + queryFn: () => Promise.resolve('always'), + enabled: true as const, + }, + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(true), + enabled: false, + initialData: false, + }, + ], + })) + + expectTypeOf(queryResults[0].data).toEqualTypeOf() + expectTypeOf(queryResults[1].data).toEqualTypeOf() + expectTypeOf(queryResults[2].data).toEqualTypeOf() }) it('should return correct data for dynamic queries with mixed result types', () => { diff --git a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx index f85e85a3cd0..1cbc9b3b29b 100644 --- a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx @@ -10,6 +10,7 @@ import { Errored, Loading, createSignal } from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, useQuery } from '..' import { renderWithClient } from './utils' +import type { UseQueryResult } from '..' describe('useQuery 2.0 read semantics', () => { let queryCache: QueryCache @@ -273,17 +274,19 @@ describe('useQuery 2.0 read semantics', () => { expect(rendered.getByText('written')).toBeInTheDocument() }) - it('suspends a disabled query until it is enabled', async () => { + it('reads undefined for a disabled query and suspends once it is enabled', async () => { const key = queryKey() const [enabled, setEnabled] = createSignal(false) + let state!: UseQueryResult + function Page() { - const state = useQuery(() => ({ + state = useQuery(() => ({ queryKey: key, queryFn: () => sleep(10).then(() => 'ready'), enabled: enabled(), })) - return {state.data} + return data: {String(state.data)} } const rendered = renderWithClient(queryClient, () => ( @@ -292,14 +295,70 @@ describe('useQuery 2.0 read semantics', () => { )) - expect(rendered.getByText('loading')).toBeInTheDocument() + expect(rendered.getByText('data: undefined')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(20) - // Still parked: disabled means nothing is in flight to wait for. - expect(rendered.getByText('loading')).toBeInTheDocument() + expect(rendered.getByText('data: undefined')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(state.isPending).toBe(true) + expect(state.isLoading).toBe(false) + // the committed `undefined` read holds while the first fetch runs; + // `isLoading` reports it (`data: undefined, isLoading: true`) setEnabled(true) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('data: undefined')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(state.isLoading).toBe(true) + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: ready')).toBeInTheDocument() + expect(state.isLoading).toBe(false) + }) + + it('does not hold a boundary hostage when the enabling control is inside it', async () => { + const key = queryKey() + const [term, setTerm] = createSignal('') + const queryFn = vi.fn((ctx: { queryKey: ReadonlyArray }) => + sleep(10).then(() => `results for ${ctx.queryKey[1]}`), + ) + + function Search() { + const results = useQuery(() => ({ + queryKey: [key, term()], + queryFn, + enabled: term() !== '', + })) + return ( +
+ setTerm(e.currentTarget.value)} + /> + {results.data ?? 'type to search'} +
+ ) + } + + const rendered = renderWithClient(queryClient, () => ( + loading
}> + + + )) + + expect(rendered.getByText('type to search')).toBeInTheDocument() + expect(rendered.getByLabelText('term')).toBeInTheDocument() + expect(queryFn).not.toHaveBeenCalled() + + fireEvent.input(rendered.getByLabelText('term'), { + target: { value: 'solid' }, + }) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByLabelText('term')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(10) - expect(rendered.getByText('ready')).toBeInTheDocument() + expect(rendered.getByText('results for solid')).toBeInTheDocument() + expect(rendered.getByLabelText('term')).toBeInTheDocument() }) it('exposes background fetch state reactively', async () => { diff --git a/packages/solid-query/src/__tests__/useQuery.test-d.tsx b/packages/solid-query/src/__tests__/useQuery.test-d.tsx index de57a931414..5c770a03f5b 100644 --- a/packages/solid-query/src/__tests__/useQuery.test-d.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test-d.tsx @@ -1,5 +1,6 @@ import { describe, expectTypeOf, it } from 'vitest' import { queryKey } from '@tanstack/query-test-utils' +import { skipToken } from '@tanstack/query-core' import { queryOptions, useQuery } from '../index' import type { OmitKeyof, QueryFunction, UseQueryOptions } from '..' @@ -249,6 +250,77 @@ describe('useQuery', () => { }) }) + describe('queries that can be disabled', () => { + it('TData includes undefined when enabled is a boolean', () => { + const { data } = useQuery(() => ({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + enabled: Math.random() > 0.5, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + it('TData includes undefined when enabled is a callback', () => { + const { data } = useQuery(() => ({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + enabled: (query) => query.state.dataUpdateCount === 0, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + it('TData stays defined when enabled is literally true', () => { + const { data } = useQuery(() => ({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + enabled: true as const, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('TData includes undefined when queryFn may be skipToken', () => { + const { data } = useQuery(() => ({ + queryKey: queryKey(), + queryFn: Math.random() > 0.5 ? skipToken : () => ({ wow: true }), + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + it('TData stays defined when a query that can be disabled has initialData', () => { + const { data } = useQuery(() => ({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + enabled: false, + initialData: { wow: true }, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('carries through queryOptions', () => { + const canDisable = queryOptions({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + enabled: false, + }) + const always = queryOptions({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + }) + + expectTypeOf(useQuery(() => canDisable).data).toEqualTypeOf< + { wow: boolean } | undefined + >() + expectTypeOf(useQuery(() => always).data).toEqualTypeOf<{ + wow: boolean + }>() + }) + }) + describe('generic indexed access TData', () => { // https://github.com/TanStack/query/issues/9937 it('should be assignable back to its source indexed type when passed to a generic function parameter', () => { diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index ed1b4405245..b0d81424579 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -295,7 +295,7 @@ describe('useQuery', () => { it('should not cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we do not have data yet', async () => { const key = queryKey() let fetchCount = 0 - let state!: UseQueryResult + let state!: UseQueryResult function Page() { state = useQuery(() => ({ @@ -671,7 +671,7 @@ describe('useQuery', () => { it('should not update disabled query when refetch with refetchQueries', async () => { const key = queryKey() let count = 0 - let state!: UseQueryResult + let state!: UseQueryResult function Page() { state = useQuery(() => ({ @@ -701,7 +701,7 @@ describe('useQuery', () => { it('should not refetch disabled query when invalidated with invalidateQueries', async () => { const key = queryKey() let count = 0 - let state!: UseQueryResult + let state!: UseQueryResult function Page() { state = useQuery(() => ({ @@ -732,7 +732,7 @@ describe('useQuery', () => { const key = queryKey() const [count, setCount] = createSignal(0) let fetches = 0 - let state!: UseQueryResult + let state!: UseQueryResult function Page() { state = useQuery(() => ({ @@ -759,15 +759,12 @@ describe('useQuery', () => { setCount(1) await vi.advanceTimersByTimeAsync(10) - // Switching to a disabled key never fetches; the committed value from the - // previous key holds while the new (never-arriving) read stays pending. + // switching to a disabled key never fetches; the read is `undefined` expect(fetches).toBe(1) expect(state.status).toBe('pending') expect(state.fetchStatus).toBe('idle') + expect(rendered.getByText('data:')).toBeInTheDocument() - // Settle the parked read before the test ends: a transition held on a - // never-resolving promise outlives unmount in the global reactive engine - // and would corrupt later tests. setCount(0) await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('data: 0')).toBeInTheDocument() @@ -855,15 +852,10 @@ describe('useQuery', () => { expect(state.isFetching).toBe(false) }) - // The key switches park the data node on the never-resolving pending read - // (disabled query, nothing to fetch), so the committed UI holds the - // previous key's data through the transition. `refetch()` syncs the - // observer to the latest computed options at call time — the deferred - // setOptions render effect can't be relied on under a held transition. it('should keep the previous data on disabled query when placeholderData is set and switching query key multiple times', async () => { const key = queryKey() const [count, setCount] = createSignal(10) - let state!: UseQueryResult + let state!: UseQueryResult queryClient.setQueryData([key, 10], 10) @@ -890,14 +882,17 @@ describe('useQuery', () => { setCount(11) await vi.advanceTimersByTimeAsync(0) expect(rendered.getByText('data: 10')).toBeInTheDocument() + expect(state.isPlaceholderData).toBe(true) setCount(12) await vi.advanceTimersByTimeAsync(0) expect(rendered.getByText('data: 10')).toBeInTheDocument() + expect(state.isPlaceholderData).toBe(true) void state.refetch() await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('data: 12')).toBeInTheDocument() + expect(state.isPlaceholderData).toBe(false) }) it('should use the correct query function when components use different configurations', async () => { @@ -2377,7 +2372,7 @@ describe('useQuery', () => { const key = queryKey() const [enabled, setEnabled] = createSignal(false) let count = 0 - let state!: UseQueryResult + let state!: UseQueryResult function Page() { state = useQuery(() => ({ @@ -2399,8 +2394,8 @@ describe('useQuery', () => { const rendered = renderWithClient(queryClient, () => ) - // disabled with nothing cached: the read parks in - expect(rendered.getByText('loading')).toBeInTheDocument() + // disabled with nothing cached: the read is `undefined`, no suspension + expect(rendered.getByText('data: undefined')).toBeInTheDocument() expect(count).toBe(0) queryClient.prefetchQuery({ @@ -2408,7 +2403,7 @@ describe('useQuery', () => { queryFn: () => Promise.resolve('prefetched data'), }) await vi.advanceTimersByTimeAsync(0) - // the cache write revives the parked read even while disabled + // the cache write is served even while disabled expect(rendered.getByText('data: prefetched data')).toBeInTheDocument() expect(count).toBe(0) @@ -2435,9 +2430,9 @@ describe('useQuery', () => { return (
-
FetchStatus: {query.fetchStatus}
- no data}> -

Data: {query.data}

+
Loading: {String(query.isLoading)}
+ fallback}> +

Data: {query.data ?? 'no data'}

) @@ -2445,15 +2440,19 @@ describe('useQuery', () => { const rendered = renderWithClient(queryClient, () => ) - expect(rendered.getByText('FetchStatus: idle')).toBeInTheDocument() - expect(rendered.getByText('no data')).toBeInTheDocument() + expect(rendered.getByText('Loading: false')).toBeInTheDocument() + expect(rendered.getByText('Data: no data')).toBeInTheDocument() + expect(rendered.queryByText('fallback')).not.toBeInTheDocument() setShouldFetch(true) await vi.advanceTimersByTimeAsync(0) - expect(rendered.getByText('FetchStatus: fetching')).toBeInTheDocument() + // the committed `undefined` read holds while the first fetch runs + expect(rendered.getByText('Loading: true')).toBeInTheDocument() + expect(rendered.getByText('Data: no data')).toBeInTheDocument() + expect(rendered.queryByText('fallback')).not.toBeInTheDocument() await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('Data: data')).toBeInTheDocument() - expect(rendered.getByText('FetchStatus: idle')).toBeInTheDocument() + expect(rendered.getByText('Loading: false')).toBeInTheDocument() }) // See https://github.com/TanStack/query/issues/7711 @@ -3055,7 +3054,7 @@ describe('useQuery', () => { it('should use placeholder data even for disabled queries', async () => { const key1 = queryKey() const [count, setCount] = createSignal(0) - let state!: UseQueryResult + let state!: UseQueryResult function Page() { state = useQuery(() => ({ @@ -3538,7 +3537,7 @@ describe('useQuery', () => { it('should update query state and not refetch when resetting a disabled query with resetQueries', async () => { const key = queryKey() let count = 0 - let state!: UseQueryResult + let state!: UseQueryResult function Page() { state = useQuery(() => ({ @@ -3567,8 +3566,8 @@ describe('useQuery', () => { const rendered = renderWithClient(queryClient, () => ) - // Disabled query with no data: the data read stays pending - expect(rendered.getByText('loading')).toBeInTheDocument() + expect(rendered.getByText('data:')).toBeInTheDocument() + expect(rendered.queryByText('loading')).not.toBeInTheDocument() expect(state.isPending).toBe(true) expect(state.isFetching).toBe(false) @@ -3580,17 +3579,12 @@ describe('useQuery', () => { fireEvent.click(rendered.getByRole('button', { name: /reset/i })) await vi.advanceTimersByTimeAsync(10) // Resetting a disabled query does not refetch + expect(rendered.getByText('data:')).toBeInTheDocument() expect(state.isPending).toBe(true) + expect(state.isFetching).toBe(false) expect(state.fetchStatus).toBe('idle') expect(count).toBe(1) - // PORT-REVIEW (kept running): `state.isFetching` reports true here even - // though nothing fetches — the isFetching projection ORs in a - // "value pending" probe on the data node, and after a reset the disabled - // query's data read is parked pending forever. Asserting via fetchStatus - // (which correctly reads 'idle') instead. See port-notes/useQuery.md. - - // Settle the parked pending read before the test ends (a never-resolving - // read held past unmount corrupts the global reactive engine). + fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('data: 2')).toBeInTheDocument() diff --git a/packages/solid-query/src/index.ts b/packages/solid-query/src/index.ts index 4325d5aa157..7dfc6f04cbd 100644 --- a/packages/solid-query/src/index.ts +++ b/packages/solid-query/src/index.ts @@ -35,6 +35,7 @@ export type { export { useQuery } from './useQuery' export { queryOptions } from './queryOptions' export type { + AlwaysEnabledOptions, DefinedInitialDataOptions, UndefinedInitialDataOptions, } from './queryOptions' @@ -50,6 +51,7 @@ export { useIsFetching } from './useIsFetching' export { useInfiniteQuery } from './useInfiniteQuery' export { infiniteQueryOptions } from './infiniteQueryOptions' export type { + AlwaysEnabledInfiniteOptions, DefinedInitialDataInfiniteOptions, UndefinedInitialDataInfiniteOptions, } from './infiniteQueryOptions' diff --git a/packages/solid-query/src/infiniteQueryOptions.ts b/packages/solid-query/src/infiniteQueryOptions.ts index 6f4885d999f..7d45d417682 100644 --- a/packages/solid-query/src/infiniteQueryOptions.ts +++ b/packages/solid-query/src/infiniteQueryOptions.ts @@ -3,11 +3,28 @@ import type { DefaultError, InfiniteData, NonUndefinedGuard, + QueryFunction, QueryKey, } from '@tanstack/query-core' import type { InfiniteQueryOptions } from './types' import type { Accessor } from 'solid-js' +/** Options that cannot disable the query and carry no `initialData`: `data` is `TData`. */ +export type AlwaysEnabledInfiniteOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = Accessor< + InfiniteQueryOptions & { + initialData?: undefined + enabled?: true + queryFn?: QueryFunction + } +> + +/** Options without `initialData` that may disable the query: `data` is `TData | undefined`. */ export type UndefinedInitialDataInfiniteOptions< TQueryFnData, TError = DefaultError, @@ -34,6 +51,33 @@ export type DefinedInitialDataInfiniteOptions< | (() => NonUndefinedGuard>) } > +export function infiniteQueryOptions< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: ReturnType< + AlwaysEnabledInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > + >, +): ReturnType< + AlwaysEnabledInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > +> & { + queryKey: DataTag> +} export function infiniteQueryOptions< TQueryFnData, TError = DefaultError, diff --git a/packages/solid-query/src/queryOptions.ts b/packages/solid-query/src/queryOptions.ts index fea9db1e1f3..f00786febdf 100644 --- a/packages/solid-query/src/queryOptions.ts +++ b/packages/solid-query/src/queryOptions.ts @@ -1,7 +1,27 @@ -import type { DataTag, DefaultError, QueryKey } from '@tanstack/query-core' +import type { + DataTag, + DefaultError, + QueryFunction, + QueryKey, +} from '@tanstack/query-core' import type { QueryOptions } from './types' import type { Accessor } from 'solid-js' +/** Options that cannot disable the query and carry no `initialData`: `data` is `TData`. */ +export type AlwaysEnabledOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Accessor< + QueryOptions & { + initialData?: undefined + enabled?: true + queryFn?: QueryFunction + } +> + +/** Options without `initialData` that may disable the query: `data` is `TData | undefined`. */ export type UndefinedInitialDataOptions< TQueryFnData = unknown, TError = DefaultError, @@ -24,6 +44,19 @@ export type DefinedInitialDataOptions< } > +export function queryOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: ReturnType< + AlwaysEnabledOptions + >, +): ReturnType> & { + queryKey: DataTag +} + export function queryOptions< TQueryFnData = unknown, TError = DefaultError, diff --git a/packages/solid-query/src/types.ts b/packages/solid-query/src/types.ts index 927af03e543..98561f6c372 100644 --- a/packages/solid-query/src/types.ts +++ b/packages/solid-query/src/types.ts @@ -70,12 +70,13 @@ export type UseQueryOptions< /* --- Create Query and Create Base Query Types --- */ /** - * `data` is non-optional: it is a suspending async read. It never returns - * `undefined` — a read either suspends into the nearest `` - * boundary (first fetch in flight, disabled, restoring), returns a value + * `data` is a suspending async read: a read either suspends into the nearest + * `` boundary (first fetch in flight, restoring), returns a value * (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. + * `throwOnError`), so it is `TData` rather than the v5 `TData | undefined`. + * The exception is a disabled query with nothing cached, which reads + * `undefined` without suspending; the hook overloads widen `data` for + * options that can disable the query (`enabled`, `skipToken`). */ export type UseBaseQueryResult< TData = unknown, diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index d152a685349..82848d23575 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -28,12 +28,10 @@ import type { const isServer = typeof window === 'undefined' /** - * A read of a pending-idle query (disabled, or reset with nothing in flight) - * has no value and nothing to wait on. Parking the reader on a promise that - * never resolves suspends it into the nearest `` boundary until the - * query actually starts fetching (enabling it, a refetch, a cache write) — - * at which point the version bump re-runs the compute and the superseded - * in-flight is ignored by the engine. + * Parks a read whose answer is on its way through a channel other than a + * fetch this compute could start (hydration adoption, node priming, a + * persister restore). Each of those lands by re-running the tracked compute, + * so the promise itself never needs to settle. */ const NEVER: Promise = new Promise(noop) @@ -377,20 +375,34 @@ export function useBaseQueryLayer< return enabled !== false } + let servedQuery: + | Query + | undefined + const resolvePlaceholder = ( opts: ReturnType, + previous?: Query, ): TQueryData | undefined => { const placeholder = opts.placeholderData if (placeholder === undefined) return undefined - // The function form receives previous data/query in React Query as a - // `keepPreviousData` vehicle. Solid 2 holds the previous committed value - // natively while a new promise is pending, so previous-data plumbing is - // unnecessary here; a function placeholder computes from nothing. + // The function form is React Query's `keepPreviousData` vehicle. While a + // fetch is pending, Solid 2 holds the previous committed value natively, + // so no `previous` is passed. A disabled query has no pending fetch to + // hold against, so there the previously served query is supplied. return typeof placeholder === 'function' - ? (placeholder as () => TQueryData | undefined)() + ? ( + placeholder as ( + previousData: TQueryData | undefined, + previousQuery: + | Query + | undefined, + ) => TQueryData | undefined + )(previous?.state.data, previous) : placeholder } + const placeholderPrevious = () => (isEnabled() ? undefined : servedQuery) + /** * The data compute. It returns either the settled `{ value }` root or a * promise of it — the engine does the rest: pending reads suspend into @@ -431,11 +443,15 @@ export function useBaseQueryLayer< const wrap = (d: any): { value: TData } => ({ value: select ? select(d as TQueryData) : (d as TData), }) + const serve = (d: any): { value: TData } => { + servedQuery = q + return wrap(d) + } // Placeholder: show immediately instead of suspending while the first // fetch runs. When the fetch lands the version bump swaps in real data. if (state.data === undefined && state.status === 'pending') { - const placeholder = resolvePlaceholder(opts) + const placeholder = resolvePlaceholder(opts, placeholderPrevious()) if (placeholder !== undefined) return wrap(placeholder) } @@ -453,7 +469,7 @@ export function useBaseQueryLayer< if (state.fetchStatus !== 'idle') { if (state.data === undefined || prev !== undefined) { const promise = q.promise - if (promise) return chainOnce(promise, select, wrap) + if (promise) return chainOnce(promise, select, serve) } } @@ -472,16 +488,16 @@ export function useBaseQueryLayer< } } - if (state.data !== undefined) return wrap(state.data) + if (state.data !== undefined) return serve(state.data) // Pending-idle: nothing in flight, nothing cached. If the query is // enabled, the tracked read itself starts the fetch (the router // `query()` model — reads pull the async). This is not just the server - // path: on the client it is what revives a query whose enabling change - // arrives while the subtree is parked under a suspended boundary — - // parked boundaries hold effects, so the observer's option-driven fetch - // can never fire there, but computes still re-run. `q.fetch` dedupes - // against any fetch the observer already started. + // path: on the client it is what starts the fetch for a query whose + // enabling change arrives while the subtree sits under a suspended + // boundary — suspended boundaries hold effects, so the observer's + // option-driven fetch can never fire there, but computes still re-run. + // `q.fetch` dedupes against any fetch the observer already started. if (isEnabled()) { /** * Never start a fetch inside the hydration window. Adoption @@ -529,9 +545,12 @@ export function useBaseQueryLayer< * sees the identical options object — a no-op diff. */ if (!isServer) observer.setOptions(opts as any) - return chainOnce(q.fetch(opts as any), select, wrap) + return chainOnce(q.fetch(opts as any), select, serve) } - return NEVER + // Disabled with nothing cached: there is no answer and nothing will + // produce one, so the read is `undefined` rather than a suspension that + // could never settle. + return { value: undefined as TData } } /** @@ -636,7 +655,9 @@ export function useBaseQueryLayer< const hasPlaceholder = () => meta.status === 'pending' && - untrack(() => resolvePlaceholder(defaultedOptions())) !== undefined + untrack(() => + resolvePlaceholder(defaultedOptions(), placeholderPrevious()), + ) !== undefined const status = () => (hasPlaceholder() ? 'success' : meta.status) const isPending = () => status() === 'pending' /** diff --git a/packages/solid-query/src/useInfiniteQuery.ts b/packages/solid-query/src/useInfiniteQuery.ts index 22a0bab33f1..6afe68597a5 100644 --- a/packages/solid-query/src/useInfiniteQuery.ts +++ b/packages/solid-query/src/useInfiniteQuery.ts @@ -18,6 +18,7 @@ import type { } from './types' import type { Accessor } from 'solid-js' import type { + AlwaysEnabledInfiniteOptions, DefinedInitialDataInfiniteOptions, UndefinedInitialDataInfiniteOptions, } from './infiniteQueryOptions' @@ -97,7 +98,7 @@ export function useInfiniteQuery< TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, >( - options: UndefinedInitialDataInfiniteOptions< + options: AlwaysEnabledInfiniteOptions< TQueryFnData, TError, TData, @@ -106,6 +107,22 @@ export function useInfiniteQuery< >, queryClient?: Accessor, ): UseInfiniteQueryResult +export function useInfiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + options: UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + queryClient?: Accessor, +): UseInfiniteQueryResult export function useInfiniteQuery< TQueryFnData, diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 3c93ed6bd26..b42dbb17735 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -40,6 +40,30 @@ type MAXIMUM_DEPTH = 20 // Widen the type of the symbol to enable type inference even if skipToken is not immutable. type SkipTokenForUseQueries = symbol +type HasDefinedInitialData = 'initialData' extends keyof T + ? {} extends Pick + ? false + : undefined extends T['initialData' & keyof T] + ? false + : true + : false + +// `TData | undefined` when the entry can disable its query (same rule as the `useQuery` overloads). +type DataFor = + HasDefinedInitialData extends true + ? TData + : 'enabled' extends keyof T + ? Exclude extends true + ? TData + : TData | undefined + : 'queryFn' extends keyof T + ? [ + Extract, symbol>, + ] extends [never] + ? TData + : TData | undefined + : TData + type GetOptions = // Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData } T extends { @@ -100,7 +124,7 @@ type GetResults = throwOnError?: ThrowOnError } ? UseQueryResult< - unknown extends TData ? TQueryFnData : TData, + DataFor, unknown extends TError ? DefaultError : TError > : // Fallback @@ -187,7 +211,8 @@ export function useQueries< * whose options stay reactive through the per-index accessor — option * changes flow into the existing row without tearing it down, while * length changes create/dispose tail rows. Every result has identical - * semantics to `useQuery`: reads suspend, settled data is non-nullable. + * semantics to `useQuery`: reads suspend while a fetch is in flight, a + * disabled entry with nothing cached reads `undefined`. */ const length = createMemo(() => queriesOptions().queries.length) const results = repeat(length, (index) => { diff --git a/packages/solid-query/src/useQuery.ts b/packages/solid-query/src/useQuery.ts index e8efb4796a9..52c9c1dad72 100644 --- a/packages/solid-query/src/useQuery.ts +++ b/packages/solid-query/src/useQuery.ts @@ -10,6 +10,7 @@ import type { UseQueryResult, } from './types' import type { + AlwaysEnabledOptions, DefinedInitialDataOptions, UndefinedInitialDataOptions, } from './queryOptions' @@ -20,7 +21,17 @@ export function useQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - options: UndefinedInitialDataOptions, + options: DefinedInitialDataOptions, + queryClient?: () => QueryClient, +): DefinedUseQueryResult + +export function useQuery< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: AlwaysEnabledOptions, queryClient?: () => QueryClient, ): UseQueryResult @@ -30,9 +41,9 @@ export function useQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - options: DefinedInitialDataOptions, + options: UndefinedInitialDataOptions, queryClient?: () => QueryClient, -): DefinedUseQueryResult +): UseQueryResult export function useQuery< TQueryFnData, TError = DefaultError,