From 936d50078c0f9ac3d0ca4fc7778440522499b9e9 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 25 Aug 2026 13:54:12 -0500 Subject: [PATCH 1/6] feat(solid-query): type useQuery/useInfiniteQuery data as non-nullable Reading data on a useQuery/useInfiniteQuery result is backed by an async resource that suspends the component into the nearest Loading boundary while the query loads, so by the time data is read during render it has settled. Reflect that in the types: data is TData, never undefined. - Add a distributive NonNullableData wrapper and apply it to UseBaseQueryResult and UseInfiniteQueryResult, keeping each status variant's other discriminants intact. - useQueries results are a plain reactive store with no resource backing (reads never suspend), so keep its data nullable via a local alias. - Update type tests for the new expectations, and widen intentional pending-state observations in runtime tests through a new pendingData test helper, which documents that those reads happen before the value exists at runtime. --- .../__tests__/infiniteQueryOptions.test-d.tsx | 2 +- .../src/__tests__/queryOptions.test-d.tsx | 2 +- .../src/__tests__/suspense.test.tsx | 4 +- .../src/__tests__/useInfiniteQuery.test-d.tsx | 18 +++----- .../src/__tests__/useInfiniteQuery.test.tsx | 44 ++++++++++--------- .../src/__tests__/useQueries.test-d.tsx | 15 ++++--- .../src/__tests__/useQuery.test-d.tsx | 38 ++++++++-------- .../src/__tests__/useQuery.test.tsx | 31 +++++++------ packages/solid-query/src/__tests__/utils.tsx | 13 ++++++ packages/solid-query/src/types.ts | 20 ++++++++- packages/solid-query/src/useQueries.ts | 10 ++++- 11 files changed, 118 insertions(+), 79 deletions(-) diff --git a/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx b/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx index 5d212f7edf3..92b18e32591 100644 --- a/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx +++ b/packages/solid-query/src/__tests__/infiniteQueryOptions.test-d.tsx @@ -52,7 +52,7 @@ describe('infiniteQueryOptions', () => { }) expectTypeOf(() => useInfiniteQuery(() => options).data).toEqualTypeOf< - () => InfiniteData<{ wow: boolean }, unknown> | undefined + () => InfiniteData<{ wow: boolean }, unknown> >() expectTypeOf(options).toExtend< diff --git a/packages/solid-query/src/__tests__/queryOptions.test-d.tsx b/packages/solid-query/src/__tests__/queryOptions.test-d.tsx index 299536290e6..791d9ed38d2 100644 --- a/packages/solid-query/src/__tests__/queryOptions.test-d.tsx +++ b/packages/solid-query/src/__tests__/queryOptions.test-d.tsx @@ -37,7 +37,7 @@ describe('queryOptions', () => { }) const { data } = useQuery(() => options) - expectTypeOf(data).toEqualTypeOf() + expectTypeOf(data).toEqualTypeOf() }) it('should work when passed to fetchQuery', async () => { const options = queryOptions({ diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index bc30409acf9..6b4b9717faa 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -3,7 +3,7 @@ import { fireEvent } from '@solidjs/testing-library' import { Errored, Loading, createRenderEffect, createSignal } from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, useInfiniteQuery, useQuery } from '..' -import { renderWithClient } from './utils' +import { pendingData, renderWithClient } from './utils' import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '..' describe("useQuery's in Loading mode", () => { @@ -101,7 +101,7 @@ describe("useQuery's in Loading mode", () => { return (
- data: {state.data?.pages.join(',')} + data: {pendingData(state.data)?.pages.join(',')}
) } diff --git a/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx index e1cc1dbdfd9..9bf6924e393 100644 --- a/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx +++ b/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx @@ -76,9 +76,7 @@ describe('useInfiniteQuery', () => { getNextPageParam: () => undefined, })) - expectTypeOf(data).toEqualTypeOf< - InfiniteData | undefined - >() + expectTypeOf(data).toEqualTypeOf>() }) }) @@ -94,9 +92,7 @@ describe('useInfiniteQuery', () => { })) // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now - expectTypeOf(infiniteQuery.data).toEqualTypeOf< - InfiniteData | undefined - >() + expectTypeOf(infiniteQuery.data).toEqualTypeOf>() }) it('should be able to transform data to arbitrary result', () => { @@ -113,7 +109,7 @@ describe('useInfiniteQuery', () => { }, })) - expectTypeOf(infiniteQuery.data).toEqualTypeOf<'selected' | undefined>() + expectTypeOf(infiniteQuery.data).toEqualTypeOf<'selected'>() }) }) @@ -152,9 +148,7 @@ describe('useInfiniteQuery', () => { })) // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now - expectTypeOf(infiniteQuery.data).toEqualTypeOf< - InfiniteData | undefined - >() + expectTypeOf(infiniteQuery.data).toEqualTypeOf>() }) }) @@ -198,9 +192,7 @@ describe('useInfiniteQuery', () => { ) // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now - expectTypeOf(infiniteQuery.data).toEqualTypeOf< - InfiniteData | undefined - >() + expectTypeOf(infiniteQuery.data).toEqualTypeOf>() }) }) }) diff --git a/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx index 939b30ef8fa..6ed16ae0396 100644 --- a/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx @@ -18,7 +18,7 @@ import { keepPreviousData, useInfiniteQuery, } from '..' -import { Blink, renderWithClient, setActTimeout } from './utils' +import { Blink, pendingData, renderWithClient, setActTimeout } from './utils' import type { InfiniteData, QueryFunctionContext, @@ -225,7 +225,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, isFetching: state.isFetching, @@ -240,7 +240,7 @@ describe('useInfiniteQuery', () => {
-
data: {state.data?.pages.join(',') ?? 'null'}
+
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
isFetching: {String(state.isFetching)}
) @@ -433,7 +433,7 @@ describe('useInfiniteQuery', () => { () => ({ ...state }), () => { states.push({ - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, isSuccess: state.isSuccess, @@ -444,7 +444,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {state.data?.pages.join(',') ?? 'null'}
+
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
isFetching: {state.isFetching}
) @@ -510,7 +510,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, hasNextPage: state.hasNextPage, @@ -602,7 +602,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, isFetching: state.isFetching, @@ -620,7 +620,7 @@ describe('useInfiniteQuery', () => { fetchPreviousPage -
data: {state.data?.pages.join(',') ?? 'null'}
+
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
isFetching: {String(state.isFetching)}
) @@ -747,7 +747,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, isFetching: state.isFetching, @@ -771,7 +771,7 @@ describe('useInfiniteQuery', () => { > refetch -
data: {state.data?.pages.join(',') ?? 'null'}
+
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
isFetching: {String(state.isFetching)}
) @@ -870,7 +870,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, isFetching: state.isFetching, @@ -887,7 +887,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {state.data?.pages.join(',') ?? 'null'}
+
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
isFetching: {String(state.isFetching)}
) @@ -986,7 +986,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, isFetching: state.isFetching, @@ -1005,7 +1005,7 @@ describe('useInfiniteQuery', () => { -
data: {state.data?.pages.join(',') ?? 'null'}
+
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
isFetching: {String(state.isFetching)}
) @@ -1091,7 +1091,9 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ hasNextPage: state.hasNextPage, - data: state.data ? JSON.parse(JSON.stringify(state.data)) : undefined, + data: pendingData(state.data) + ? JSON.parse(JSON.stringify(state.data)) + : undefined, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isSuccess: state.isSuccess, @@ -1434,7 +1436,7 @@ describe('useInfiniteQuery', () => { () => { states.push({ hasNextPage: state.hasNextPage, - data: state.data + data: pendingData(state.data) ? JSON.parse(JSON.stringify(state.data)) : undefined, isFetching: state.isFetching, @@ -1746,7 +1748,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {state.data?.pages.join(',') ?? 'null'}
+
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
hasNextPage: {state.hasNextPage ? 'true' : 'false'}
) @@ -1796,7 +1798,7 @@ describe('useInfiniteQuery', () => { fallback={ <>
Data:
- + {(page, i) => (
@@ -1941,7 +1943,7 @@ describe('useInfiniteQuery', () => { fallback={ <>
Data:
- + {(page, i) => (
@@ -2088,7 +2090,7 @@ describe('useInfiniteQuery', () => { ) return (
-

Status: {state.data?.pages[0]}

+

Status: {pendingData(state.data)?.pages[0]}

) } @@ -2120,7 +2122,7 @@ describe('useInfiniteQuery', () => { ) return (
-

Status: {state.data?.pages[0]}

+

Status: {pendingData(state.data)?.pages[0]}

) } diff --git a/packages/solid-query/src/__tests__/useQueries.test-d.tsx b/packages/solid-query/src/__tests__/useQueries.test-d.tsx index eaa0a42d45f..617ec56b36b 100644 --- a/packages/solid-query/src/__tests__/useQueries.test-d.tsx +++ b/packages/solid-query/src/__tests__/useQueries.test-d.tsx @@ -5,14 +5,17 @@ import { queryOptions, useQueries } from '..' import { QueryClient } from '../QueryClient' import type * as QueryCore from '@tanstack/query-core' import type { OmitKeyof } from '@tanstack/query-core' -import type { - QueryFunction, - QueryFunctionContext, - QueryKey, - UseQueryResult, -} from '..' +import type { QueryFunction, QueryFunctionContext, QueryKey } from '..' import type { QueryOptions } from '../types' +// useQueries results are a plain reactive store with no resource backing +// (reads never suspend), so unlike useQuery its `data` stays nullable — +// assert against the raw query-core observer result here. +type UseQueryResult< + TData = unknown, + TError = QueryCore.DefaultError, +> = QueryCore.QueryObserverResult + describe('useQueries', () => { it('TData should have undefined in the union even when initialData is provided as an object', () => { const query1 = { diff --git a/packages/solid-query/src/__tests__/useQuery.test-d.tsx b/packages/solid-query/src/__tests__/useQuery.test-d.tsx index 278f4710b87..f37b7caa0bc 100644 --- a/packages/solid-query/src/__tests__/useQuery.test-d.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test-d.tsx @@ -16,7 +16,7 @@ describe('useQuery', () => { queryKey: key, queryFn: () => 'test', })) - expectTypeOf(fromQueryFn.data).toEqualTypeOf() + expectTypeOf(fromQueryFn.data).toEqualTypeOf() expectTypeOf(fromQueryFn.error).toEqualTypeOf() // it should be possible to specify the result type @@ -24,7 +24,7 @@ describe('useQuery', () => { queryKey: key, queryFn: () => 'test', })) - expectTypeOf(withResult.data).toEqualTypeOf() + expectTypeOf(withResult.data).toEqualTypeOf() expectTypeOf(withResult.error).toEqualTypeOf() // it should be possible to specify the error type @@ -32,7 +32,7 @@ describe('useQuery', () => { queryKey: key, queryFn: () => 'test', })) - expectTypeOf(withError.data).toEqualTypeOf() + expectTypeOf(withError.data).toEqualTypeOf() expectTypeOf(withError.error).toEqualTypeOf() // it should provide the result type in the configuration @@ -46,12 +46,12 @@ describe('useQuery', () => { queryKey: key, queryFn: () => (Math.random() > 0.5 ? ('a' as const) : ('b' as const)), })) - expectTypeOf(unionTypeSync.data).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(unionTypeSync.data).toEqualTypeOf<'a' | 'b'>() const unionTypeAsync = useQuery<'a' | 'b'>(() => ({ queryKey: key, queryFn: () => Promise.resolve(Math.random() > 0.5 ? 'a' : 'b'), })) - expectTypeOf(unionTypeAsync.data).toEqualTypeOf<'a' | 'b' | undefined>() + expectTypeOf(unionTypeAsync.data).toEqualTypeOf<'a' | 'b'>() // should error when the query function result does not match with the specified type // @ts-expect-error @@ -66,16 +66,14 @@ describe('useQuery', () => { queryKey: key, queryFn: () => queryFn(), })) - expectTypeOf(fromGenericQueryFn.data).toEqualTypeOf() + expectTypeOf(fromGenericQueryFn.data).toEqualTypeOf() expectTypeOf(fromGenericQueryFn.error).toEqualTypeOf() const fromGenericOptionsQueryFn = useQuery(() => ({ queryKey: key, queryFn: () => queryFn(), })) - expectTypeOf(fromGenericOptionsQueryFn.data).toEqualTypeOf< - string | undefined - >() + expectTypeOf(fromGenericOptionsQueryFn.data).toEqualTypeOf() expectTypeOf(fromGenericOptionsQueryFn.error).toEqualTypeOf() type MyData = number @@ -133,7 +131,7 @@ describe('useQuery', () => { ...options, })) const test = useWrappedQuery([''], () => Promise.resolve('1')) - expectTypeOf(test.data).toEqualTypeOf() + expectTypeOf(test.data).toEqualTypeOf() // handles wrapped queries with custom fetcher passed directly to useQuery const useWrappedFuncStyleQuery = < @@ -153,7 +151,7 @@ describe('useQuery', () => { const testFuncStyle = useWrappedFuncStyleQuery([''], () => Promise.resolve(true), ) - expectTypeOf(testFuncStyle.data).toEqualTypeOf() + expectTypeOf(testFuncStyle.data).toEqualTypeOf() describe('initialData', () => { describe('Config object overload', () => { @@ -188,16 +186,20 @@ describe('useQuery', () => { expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() }) - it('TData should have undefined in the union when initialData is NOT provided', () => { + it('TData should be non-nullable even when initialData is NOT provided (reads suspend until data is ready)', () => { const { data } = useQuery(() => ({ queryKey: queryKey(), queryFn: () => ({ wow: true }), })) - expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() }) it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + // The maybe-undefined initialData function infers + // TData = { wow: boolean } | undefined through the defined-initialData + // overload, so the undefined here comes from TData itself — not from + // the (suspending, non-nullable) result wrapper. const { data } = useQuery(() => ({ queryKey: queryKey(), queryFn: () => ({ wow: true }), @@ -219,13 +221,13 @@ describe('useQuery', () => { expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() }) - it('TData should have undefined in the union when initialData is NOT provided', () => { + it('TData should be non-nullable even when initialData is NOT provided (reads suspend until data is ready)', () => { const { data } = useQuery(() => ({ queryKey: queryKey(), queryFn: () => ({ wow: true }), })) - expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() }) }) @@ -240,13 +242,13 @@ describe('useQuery', () => { expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() }) - it('TData should have undefined in the union when initialData is NOT provided', () => { + it('TData should be non-nullable even when initialData is NOT provided (reads suspend until data is ready)', () => { const { data } = useQuery(() => ({ queryKey: queryKey(), queryFn: () => ({ wow: true }), })) - expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() }) }) }) @@ -292,7 +294,7 @@ describe('useQuery', () => { // Regression guard: this call must compile. With the previous // hand-rolled NoInfer, `data` failed to flow back into the generic // indexed-access parameter `DataTypeToEntity[TDataType]`. - return data ? getLabel(props.dataType, data) : null + return getLabel(props.dataType, data) } expectTypeOf(Test).toBeFunction() diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 1b43f61a4a8..9c2044acfda 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -32,6 +32,7 @@ import { IsRestoringContext } from '../isRestoring' import { Blink, mockOnlineManagerIsOnline, + pendingData, renderWithClient, setActTimeout, } from './utils' @@ -66,7 +67,7 @@ describe('useQuery', () => { return (
-

{state.data ?? 'default'}

+

{pendingData(state.data) ?? 'default'}

) } @@ -104,10 +105,12 @@ describe('useQuery', () => { ) if (state.isPending) { - expectTypeOf(state.data).toEqualTypeOf() + // `data` is typed non-nullable in every status variant: reads + // suspend to the nearest Loading boundary until the value exists. + expectTypeOf(state.data).toEqualTypeOf() expectTypeOf(state.error).toEqualTypeOf() } else if (state.isLoadingError) { - expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.data).toEqualTypeOf() expectTypeOf(state.error).toEqualTypeOf() } else { expectTypeOf(state.data).toEqualTypeOf() @@ -873,7 +876,7 @@ describe('useQuery', () => { return (
-

{state.data ?? null}

+

{pendingData(state.data) ?? null}

) } @@ -951,7 +954,7 @@ describe('useQuery', () => { })) createTrackedEffect(() => { - if (state.data) { + if (pendingData(state.data)) { states.push(state.data) } }) @@ -1034,8 +1037,8 @@ describe('useQuery', () => { isFetching: state.isFetching, }), () => { - snapshots.push(state.data ? snapshot(state.data) : undefined) - if (state.data) { + snapshots.push(pendingData(state.data) ? snapshot(state.data) : undefined) + if (pendingData(state.data)) { itemRefs.push({ item0: state.data[0], item1: state.data[1] }) } }, @@ -1046,7 +1049,7 @@ describe('useQuery', () => { return (
- data: {String(state.data?.[1]?.done)} + data: {String(pendingData(state.data)?.[1]?.done)}
) } @@ -2060,7 +2063,7 @@ describe('useQuery', () => { return (
-

{state.data ?? 'default'}

+

{pendingData(state.data) ?? 'default'}

) } @@ -2843,7 +2846,7 @@ describe('useQuery', () => { ) return (
-
data: {state.data ?? 'null'}
+
data: {pendingData(state.data) ?? 'null'}
isFetching: {state.isFetching}
isStale: {state.isStale}
@@ -4620,7 +4623,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => state.data, + () => pendingData(state.data), (data) => { if (data) { dataRefs.push(data) @@ -4680,7 +4683,7 @@ describe('useQuery', () => { })) createTrackedEffect(() => { - if (state.data) { + if (pendingData(state.data)) { states.push(state.data) } }) @@ -4897,7 +4900,7 @@ describe('useQuery', () => { -
data: {state.data ?? 'null'}
+
data: {pendingData(state.data) ?? 'null'}
isFetching: {state.isFetching}
) @@ -4980,7 +4983,7 @@ describe('useQuery', () => { -
data: {state.data ?? 'null'}
+
data: {pendingData(state.data) ?? 'null'}
) } diff --git a/packages/solid-query/src/__tests__/utils.tsx b/packages/solid-query/src/__tests__/utils.tsx index 9cf4901ca92..bb9e0024e04 100644 --- a/packages/solid-query/src/__tests__/utils.tsx +++ b/packages/solid-query/src/__tests__/utils.tsx @@ -48,3 +48,16 @@ export function setActTimeout(fn: () => void, ms?: number) { fn() }, ms) } + +/** + * Widen a query's `data` read back to `T | undefined`. + * + * The public result types declare `data` non-nullable because reads are + * expected to suspend into a Loading boundary until the value exists. Tests + * that intentionally observe pending or error states read `data` while the + * underlying value is still undefined at runtime — this helper makes that + * mismatch explicit at the call site so their guards typecheck as necessary. + */ +export function pendingData(data: T): T | undefined { + return data +} diff --git a/packages/solid-query/src/types.ts b/packages/solid-query/src/types.ts index 1c5bd10a24d..a1ddfabe8a1 100644 --- a/packages/solid-query/src/types.ts +++ b/packages/solid-query/src/types.ts @@ -66,10 +66,26 @@ export type UseQueryOptions< /* --- Create Query and Create Base Query Types --- */ +/** + * Reading `data` on a useQuery/useInfiniteQuery result is backed by an async + * resource: while the query is loading, the component is suspended into the + * nearest `` boundary, so by the time `data` is actually read during + * render the value has settled. The type reflects that — `data` is `TData`, + * never `undefined`. + * + * Distributes over the result union so each status variant keeps its other + * discriminants (`status`, `error`, ...) and only `data` is narrowed. + */ +export type NonNullableData = TResult extends { + data: unknown +} + ? Omit & { data: TData } + : never + export type UseBaseQueryResult< TData = unknown, TError = DefaultError, -> = QueryObserverResult +> = NonNullableData, TData> export type UseQueryResult< TData = unknown, @@ -132,7 +148,7 @@ export type UseInfiniteQueryOptions< export type UseInfiniteQueryResult< TData = unknown, TError = DefaultError, -> = InfiniteQueryObserverResult +> = NonNullableData, TData> export type DefinedUseInfiniteQueryResult< TData = unknown, diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index a17455cbf8e..89e1398dfcb 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -11,7 +11,7 @@ import { } from 'solid-js' import { useQueryClient } from './QueryClientProvider' import { useIsRestoring } from './isRestoring' -import type { QueryOptions, UseQueryResult } from './types' +import type { QueryOptions } from './types' import type { Accessor } from 'solid-js' import type { QueryClient } from './QueryClient' import type { @@ -26,6 +26,14 @@ import type { ThrowOnError, } from '@tanstack/query-core' +// Unlike useQuery, useQueries results are a plain reactive store with no +// resource backing — reads never suspend, so `data` here stays nullable +// rather than using the package-wide NonNullableData result type. +type UseQueryResult< + TData = unknown, + TError = DefaultError, +> = QueryObserverResult + // This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`. // `placeholderData` function does not have a parameter type UseQueryOptionsForUseQueries< From 1d3c588499ada58fd3d3e7d1924d7b7cea2be12d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:55:58 +0000 Subject: [PATCH 2/6] ci: apply automated fixes --- .../src/__tests__/useInfiniteQuery.test-d.tsx | 12 +++++++++--- packages/solid-query/src/__tests__/useQuery.test.tsx | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx index 9bf6924e393..57cb0c6b881 100644 --- a/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx +++ b/packages/solid-query/src/__tests__/useInfiniteQuery.test-d.tsx @@ -92,7 +92,9 @@ describe('useInfiniteQuery', () => { })) // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now - expectTypeOf(infiniteQuery.data).toEqualTypeOf>() + expectTypeOf(infiniteQuery.data).toEqualTypeOf< + InfiniteData + >() }) it('should be able to transform data to arbitrary result', () => { @@ -148,7 +150,9 @@ describe('useInfiniteQuery', () => { })) // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now - expectTypeOf(infiniteQuery.data).toEqualTypeOf>() + expectTypeOf(infiniteQuery.data).toEqualTypeOf< + InfiniteData + >() }) }) @@ -192,7 +196,9 @@ describe('useInfiniteQuery', () => { ) // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now - expectTypeOf(infiniteQuery.data).toEqualTypeOf>() + expectTypeOf(infiniteQuery.data).toEqualTypeOf< + InfiniteData + >() }) }) }) diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 9c2044acfda..46cee3130ba 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -1037,7 +1037,9 @@ describe('useQuery', () => { isFetching: state.isFetching, }), () => { - snapshots.push(pendingData(state.data) ? snapshot(state.data) : undefined) + snapshots.push( + pendingData(state.data) ? snapshot(state.data) : undefined, + ) if (pendingData(state.data)) { itemRefs.push({ item0: state.data[0], item1: state.data[1] }) } From 5ff20ddece069942fe87e3f47c83770357420313 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 25 Aug 2026 14:22:13 -0500 Subject: [PATCH 3/6] test(solid-query): drop the pendingData widening helper Rework the test call sites to sit closer to the non-nullable data contract instead of widening reads back to T | undefined: - Scalar displays read data naked ({state.data} renders empty while pending), including the persist-client provider test, which already read data naked everywhere else. Assertions on the removed 'null' fallbacks now match the bare label. - Snapshot captures push state.data directly. No test in the infinite file uses the reconcile option, so data is replaced rather than mutated in place and the JSON deep-copies were redundant. The useQuery reconcile test keeps its snapshot() capture, since in-place mutation is exactly what it asserts on. - Reads that dereference data (.pages, indexing) gate on isSuccess. On the client the proxy returns the raw store value, so a pending read is undefined at runtime and dereferencing it throws a TypeError that halts the reactive system - only the isServer branch routes through the query resource and throws NotReadyError. Reads already guarded by a Switch with a pending Match need no gate, since the fallback is never evaluated while pending. - suspense.test's render effect now tracks its spread in the compute function rather than reading state in the callback, clearing the STRICT_READ_UNTRACKED warnings that run emitted. --- .../PersistQueryClientProvider.test.tsx | 4 +- .../src/__tests__/suspense.test.tsx | 12 ++-- .../src/__tests__/useInfiniteQuery.test.tsx | 58 +++++++------------ .../src/__tests__/useQuery.test.tsx | 33 +++++------ packages/solid-query/src/__tests__/utils.tsx | 13 ----- 5 files changed, 43 insertions(+), 77 deletions(-) diff --git a/packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx b/packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx index 52e5d745cb7..7abbf8b16b2 100644 --- a/packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx +++ b/packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx @@ -364,7 +364,7 @@ describe('PersistQueryClientProvider', () => { return (
-

data: {state.data ?? 'null'}

+

data: {state.data}

fetchStatus: {state.fetchStatus}

) @@ -381,7 +381,7 @@ describe('PersistQueryClientProvider', () => {
)) - expect(screen.getByText('data: null')).toBeInTheDocument() + expect(screen.getByText('data:')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(10) expect(screen.getByText('data: hydrated')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(10) diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index 6b4b9717faa..63843d2d3f0 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -3,7 +3,7 @@ import { fireEvent } from '@solidjs/testing-library' import { Errored, Loading, createRenderEffect, createSignal } from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, useInfiniteQuery, useQuery } from '..' -import { pendingData, renderWithClient } from './utils' +import { renderWithClient } from './utils' import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '..' describe("useQuery's in Loading mode", () => { @@ -37,9 +37,9 @@ describe("useQuery's in Loading mode", () => { })) createRenderEffect( - () => state, + () => ({ ...state }), (s) => { - states.push({ ...s }) + states.push(s) }, ) @@ -92,16 +92,16 @@ describe("useQuery's in Loading mode", () => { })) createRenderEffect( - () => state, + () => ({ ...state }), (s) => { - states.push({ ...s }) + states.push(s) }, ) return (
- data: {pendingData(state.data)?.pages.join(',')} + data: {state.isSuccess && state.data.pages.join(',')}
) } diff --git a/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx index 6ed16ae0396..13a7e733948 100644 --- a/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx @@ -18,7 +18,7 @@ import { keepPreviousData, useInfiniteQuery, } from '..' -import { Blink, pendingData, renderWithClient, setActTimeout } from './utils' +import { Blink, renderWithClient, setActTimeout } from './utils' import type { InfiniteData, QueryFunctionContext, @@ -225,9 +225,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isSuccess: state.isSuccess, @@ -240,7 +238,7 @@ describe('useInfiniteQuery', () => {
-
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
+
data: {state.isSuccess && state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -433,9 +431,7 @@ describe('useInfiniteQuery', () => { () => ({ ...state }), () => { states.push({ - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isSuccess: state.isSuccess, }) }, @@ -444,7 +440,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
+
data: {state.isSuccess && state.data.pages.join(',')}
isFetching: {state.isFetching}
) @@ -510,9 +506,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, hasNextPage: state.hasNextPage, hasPreviousPage: state.hasPreviousPage, isFetching: state.isFetching, @@ -602,9 +596,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isRefetching: state.isRefetching, @@ -620,7 +612,7 @@ describe('useInfiniteQuery', () => { fetchPreviousPage -
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
+
data: {state.isSuccess && state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -747,9 +739,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isFetching: state.isFetching, isFetchNextPageError: state.isFetchNextPageError, isFetchingNextPage: state.isFetchingNextPage, @@ -771,7 +761,7 @@ describe('useInfiniteQuery', () => { > refetch -
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
+
data: {state.isSuccess && state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -870,9 +860,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isFetching: state.isFetching, isFetchNextPageError: state.isFetchNextPageError, isFetchingNextPage: state.isFetchingNextPage, @@ -887,7 +875,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
+
data: {state.isSuccess && state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -986,9 +974,7 @@ describe('useInfiniteQuery', () => { }), () => { states.push({ - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isFetching: state.isFetching, isFetchNextPageError: state.isFetchNextPageError, isFetchingNextPage: state.isFetchingNextPage, @@ -1005,7 +991,7 @@ describe('useInfiniteQuery', () => { -
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
+
data: {state.isSuccess && state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -1091,9 +1077,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ hasNextPage: state.hasNextPage, - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isSuccess: state.isSuccess, @@ -1436,9 +1420,7 @@ describe('useInfiniteQuery', () => { () => { states.push({ hasNextPage: state.hasNextPage, - data: pendingData(state.data) - ? JSON.parse(JSON.stringify(state.data)) - : undefined, + data: state.data, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isSuccess: state.isSuccess, @@ -1748,7 +1730,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {pendingData(state.data)?.pages.join(',') ?? 'null'}
+
data: {state.isSuccess && state.data.pages.join(',')}
hasNextPage: {state.hasNextPage ? 'true' : 'false'}
) @@ -1798,7 +1780,7 @@ describe('useInfiniteQuery', () => { fallback={ <>
Data:
- + {(page, i) => (
@@ -2090,7 +2072,7 @@ describe('useInfiniteQuery', () => { ) return (
-

Status: {pendingData(state.data)?.pages[0]}

+

Status: {state.isSuccess && state.data.pages[0]}

) } @@ -2122,7 +2104,7 @@ describe('useInfiniteQuery', () => { ) return (
-

Status: {pendingData(state.data)?.pages[0]}

+

Status: {state.isSuccess && state.data.pages[0]}

) } diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 46cee3130ba..6865e58b584 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -32,7 +32,6 @@ import { IsRestoringContext } from '../isRestoring' import { Blink, mockOnlineManagerIsOnline, - pendingData, renderWithClient, setActTimeout, } from './utils' @@ -67,7 +66,7 @@ describe('useQuery', () => { return (
-

{pendingData(state.data) ?? 'default'}

+

{state.isPending ? 'default' : state.data}

) } @@ -876,7 +875,7 @@ describe('useQuery', () => { return (
-

{pendingData(state.data) ?? null}

+

{state.data}

) } @@ -954,7 +953,7 @@ describe('useQuery', () => { })) createTrackedEffect(() => { - if (pendingData(state.data)) { + if (state.isSuccess) { states.push(state.data) } }) @@ -1037,10 +1036,8 @@ describe('useQuery', () => { isFetching: state.isFetching, }), () => { - snapshots.push( - pendingData(state.data) ? snapshot(state.data) : undefined, - ) - if (pendingData(state.data)) { + snapshots.push(snapshot(state.data)) + if (state.isSuccess) { itemRefs.push({ item0: state.data[0], item1: state.data[1] }) } }, @@ -1051,7 +1048,7 @@ describe('useQuery', () => { return (
- data: {String(pendingData(state.data)?.[1]?.done)} + data: {state.isSuccess && String(state.data[1]?.done)}
) } @@ -2065,7 +2062,7 @@ describe('useQuery', () => { return (
-

{pendingData(state.data) ?? 'default'}

+

{state.isPending ? 'default' : state.data}

) } @@ -2848,7 +2845,7 @@ describe('useQuery', () => { ) return (
-
data: {pendingData(state.data) ?? 'null'}
+
data: {state.data}
isFetching: {state.isFetching}
isStale: {state.isStale}
@@ -4625,9 +4622,9 @@ describe('useQuery', () => { })) createRenderEffect( - () => pendingData(state.data), + () => state.data, (data) => { - if (data) { + if (state.isSuccess) { dataRefs.push(data) } }, @@ -4685,7 +4682,7 @@ describe('useQuery', () => { })) createTrackedEffect(() => { - if (pendingData(state.data)) { + if (state.isSuccess) { states.push(state.data) } }) @@ -4902,7 +4899,7 @@ describe('useQuery', () => { -
data: {pendingData(state.data) ?? 'null'}
+
data: {state.data}
isFetching: {state.isFetching}
) @@ -4985,7 +4982,7 @@ describe('useQuery', () => { -
data: {pendingData(state.data) ?? 'null'}
+
data: {state.data}
) } @@ -4996,7 +4993,7 @@ describe('useQuery', () => { )) - expect(rendered.getByText('data: null')).toBeInTheDocument() + expect(rendered.getByText('data:')).toBeInTheDocument() fireEvent.click(rendered.getByRole('button', { name: /refetch/i })) await vi.advanceTimersByTimeAsync(10) @@ -5004,7 +5001,7 @@ describe('useQuery', () => { fireEvent.click(rendered.getByRole('button', { name: /reset/i })) await vi.advanceTimersByTimeAsync(10) - expect(rendered.getByText('data: null')).toBeInTheDocument() + expect(rendered.getByText('data:')).toBeInTheDocument() expect(states.length).toBe(4) diff --git a/packages/solid-query/src/__tests__/utils.tsx b/packages/solid-query/src/__tests__/utils.tsx index bb9e0024e04..9cf4901ca92 100644 --- a/packages/solid-query/src/__tests__/utils.tsx +++ b/packages/solid-query/src/__tests__/utils.tsx @@ -48,16 +48,3 @@ export function setActTimeout(fn: () => void, ms?: number) { fn() }, ms) } - -/** - * Widen a query's `data` read back to `T | undefined`. - * - * The public result types declare `data` non-nullable because reads are - * expected to suspend into a Loading boundary until the value exists. Tests - * that intentionally observe pending or error states read `data` while the - * underlying value is still undefined at runtime — this helper makes that - * mismatch explicit at the call site so their guards typecheck as necessary. - */ -export function pendingData(data: T): T | undefined { - return data -} From dbf91c420bafda75addd6f8798f244d73ca02853 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 25 Aug 2026 14:37:35 -0500 Subject: [PATCH 4/6] fix(solid-query): suspend client data reads while the query is loading The non-nullable data type only held on the server. In the result Proxy, just the isServer branch routed reads through the query resource (which throws NotReadyError); on the client the Proxy handed back the raw store value, so a pending data read was undefined and dereferencing it threw a TypeError that halted the reactive system. Suspend on the client too: a tracked data read while isLoading throws NotReadyError to the nearest boundary. The state reads in the check re-subscribe the reader, so it re-runs when the subscriber syncs the settled result. Only isLoading suspends - a pending-but-idle query (disabled, or reset with nothing in flight) still yields undefined rather than parking the boundary forever. Untracked reads pass through, so event handlers and effect callbacks can peek without suspending. This also fixes a hydration bug: the server suspended and rendered the resolved markup while the client rendered a pending pass and appended a second copy instead of claiming it, duplicating server-rendered lists. Both sides now suspend alike and hydration claims the markup. Tests read data naked again, with no status gates: - Effects that tracked the whole result via { ...state } now track deep(state), which subscribes to every property without routing data through the suspending read, so pending states stay observable and every existing assertion holds unchanged. - Narrow effects that tracked data now track dataUpdatedAt, keeping the same re-run points; the one effect that pushes its own computed record reads data through untrack to keep that record intact. - Guards on data inside tracking scopes are gone, since a tracked read can no longer observe undefined. --- .../src/__tests__/suspense.test.tsx | 16 ++- .../src/__tests__/useInfiniteQuery.test.tsx | 61 +++++----- .../src/__tests__/useQuery.test.tsx | 115 +++++++++--------- packages/solid-query/src/useBaseQuery.ts | 21 ++++ 4 files changed, 117 insertions(+), 96 deletions(-) diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index 63843d2d3f0..c6d6cbaabf6 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent } from '@solidjs/testing-library' -import { Errored, Loading, createRenderEffect, createSignal } from 'solid-js' +import { + Errored, + Loading, + createRenderEffect, + createSignal, + deep, +} from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryCache, QueryClient, useInfiniteQuery, useQuery } from '..' import { renderWithClient } from './utils' @@ -37,14 +43,14 @@ describe("useQuery's in Loading mode", () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), (s) => { states.push(s) }, ) createRenderEffect( - () => [{ ...state }, () => key], + () => [deep(state), () => key], () => { renders++ }, @@ -92,7 +98,7 @@ describe("useQuery's in Loading mode", () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), (s) => { states.push(s) }, @@ -101,7 +107,7 @@ describe("useQuery's in Loading mode", () => { return (
- data: {state.isSuccess && state.data.pages.join(',')} + data: {state.data.pages.join(',')}
) } diff --git a/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx index 13a7e733948..d3548b744c9 100644 --- a/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useInfiniteQuery.test.tsx @@ -8,7 +8,9 @@ import { Switch, createRenderEffect, createSignal, + deep, snapshot, + untrack, } from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' import { @@ -63,7 +65,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -216,13 +218,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ - data: state.data, - isFetching: state.isFetching, - isFetchingNextPage: state.isFetchingNextPage, - isSuccess: state.isSuccess, - isPlaceholderData: state.isPlaceholderData, - }), + () => deep(state), () => { states.push({ data: state.data, @@ -238,7 +234,7 @@ describe('useInfiniteQuery', () => {
-
data: {state.isSuccess && state.data.pages.join(',')}
+
data: {state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -328,7 +324,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => { renderCount++ - return { status: state.status, data: state.data } + return { status: state.status, dataUpdatedAt: state.dataUpdatedAt } }, () => { states.push(snapshot(state) as any) @@ -380,7 +376,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), (s) => { states.push(s) }, @@ -428,7 +424,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push({ data: state.data, @@ -440,7 +436,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {state.isSuccess && state.data.pages.join(',')}
+
data: {state.data.pages.join(',')}
isFetching: {state.isFetching}
) @@ -496,7 +492,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, hasNextPage: state.hasNextPage, hasPreviousPage: state.hasPreviousPage, isFetching: state.isFetching, @@ -588,7 +584,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isRefetching: state.isRefetching, @@ -612,7 +608,7 @@ describe('useInfiniteQuery', () => { fetchPreviousPage -
data: {state.isSuccess && state.data.pages.join(',')}
+
data: {state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -728,7 +724,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isFetchNextPageError: state.isFetchNextPageError, isFetchingNextPage: state.isFetchingNextPage, @@ -761,7 +757,7 @@ describe('useInfiniteQuery', () => { > refetch -
data: {state.isSuccess && state.data.pages.join(',')}
+
data: {state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -849,7 +845,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isFetchNextPageError: state.isFetchNextPageError, isFetchingNextPage: state.isFetchingNextPage, @@ -875,7 +871,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {state.isSuccess && state.data.pages.join(',')}
+
data: {state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -963,7 +959,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isFetchNextPageError: state.isFetchNextPageError, isFetchingNextPage: state.isFetchingNextPage, @@ -991,7 +987,7 @@ describe('useInfiniteQuery', () => { -
data: {state.isSuccess && state.data.pages.join(',')}
+
data: {state.data.pages.join(',')}
isFetching: {String(state.isFetching)}
) @@ -1076,8 +1072,9 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ + data: untrack(() => state.data), hasNextPage: state.hasNextPage, - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isSuccess: state.isSuccess, @@ -1305,7 +1302,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1412,7 +1409,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ hasNextPage: state.hasNextPage, - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isSuccess: state.isSuccess, @@ -1508,7 +1505,7 @@ describe('useInfiniteQuery', () => { createRenderEffect( () => ({ hasNextPage: state.hasNextPage, - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isFetchingNextPage: state.isFetchingNextPage, isSuccess: state.isSuccess, @@ -1583,7 +1580,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1631,7 +1628,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1679,7 +1676,7 @@ describe('useInfiniteQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1730,7 +1727,7 @@ describe('useInfiniteQuery', () => { return (
-
data: {state.isSuccess && state.data.pages.join(',')}
+
data: {state.data.pages.join(',')}
hasNextPage: {state.hasNextPage ? 'true' : 'false'}
) @@ -2072,7 +2069,7 @@ describe('useInfiniteQuery', () => { ) return (
-

Status: {state.isSuccess && state.data.pages[0]}

+

Status: {state.data.pages[0]}

) } @@ -2104,7 +2101,7 @@ describe('useInfiniteQuery', () => { ) return (
-

Status: {state.isSuccess && state.data.pages[0]}

+

Status: {state.data.pages[0]}

) } diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 6865e58b584..71fc82b34fa 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -17,6 +17,7 @@ import { createRenderEffect, createSignal, createTrackedEffect, + deep, reconcile, snapshot, untrack, @@ -95,7 +96,7 @@ describe('useQuery', () => { createRenderEffect( () => ({ status: state.status, - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, }), () => { @@ -345,7 +346,7 @@ describe('useQuery', () => { queryFn: () => sleep(10).then(() => 'data'), })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -501,7 +502,7 @@ describe('useQuery', () => { function Page() { const state = useQuery(() => ({ queryKey: key })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -551,7 +552,7 @@ describe('useQuery', () => { gcTime: 0, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -615,7 +616,7 @@ describe('useQuery', () => { refetchOnMount: false, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -649,7 +650,7 @@ describe('useQuery', () => { refetchOnMount: false, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -680,7 +681,7 @@ describe('useQuery', () => { select: (data) => data.name, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -712,7 +713,7 @@ describe('useQuery', () => { select: (data) => data.name, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -744,7 +745,7 @@ describe('useQuery', () => { select: (data) => data.name, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -778,7 +779,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -820,7 +821,7 @@ describe('useQuery', () => { }, })) createRenderEffect( - () => ({ status: state.status, data: state.data, error: state.error }), + () => ({ status: state.status, dataUpdatedAt: state.dataUpdatedAt, error: state.error }), () => { const s = snapshot(state) if (s.status === 'pending') @@ -857,7 +858,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -906,7 +907,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -953,9 +954,7 @@ describe('useQuery', () => { })) createTrackedEffect(() => { - if (state.isSuccess) { - states.push(state.data) - } + states.push(state.data) }) const refetch = untrack(() => state.refetch) @@ -1032,7 +1031,7 @@ describe('useQuery', () => { createRenderEffect( () => ({ status: state.status, - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, }), () => { @@ -1048,7 +1047,7 @@ describe('useQuery', () => { return (
- data: {state.isSuccess && String(state.data[1]?.done)} + data: {String(state.data[1]?.done)}
) } @@ -1152,7 +1151,7 @@ describe('useQuery', () => { createRenderEffect( () => ({ status: state.status, - data: state.data, + dataUpdatedAt: state.dataUpdatedAt, isFetching: state.isFetching, isRefetching: state.isRefetching, isSuccess: state.isSuccess, @@ -1237,7 +1236,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1286,7 +1285,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1332,7 +1331,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1389,7 +1388,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1459,7 +1458,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1542,7 +1541,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1615,7 +1614,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1692,7 +1691,7 @@ describe('useQuery', () => { staleTime: 100, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), (s) => { states1.push(s) }, @@ -1707,7 +1706,7 @@ describe('useQuery', () => { staleTime: 10, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), (s) => { states2.push(s) }, @@ -1788,7 +1787,7 @@ describe('useQuery', () => { staleTime: 50, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -1822,7 +1821,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2094,7 +2093,7 @@ describe('useQuery', () => { refetchOnWindowFocus: false, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2130,7 +2129,7 @@ describe('useQuery', () => { refetchOnWindowFocus: () => false, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2166,7 +2165,7 @@ describe('useQuery', () => { refetchOnWindowFocus: true, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2202,7 +2201,7 @@ describe('useQuery', () => { refetchOnWindowFocus: 'always', })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2243,7 +2242,7 @@ describe('useQuery', () => { refetchOnWindowFocus: (query) => (query.state.data || 0) < 1, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2305,7 +2304,7 @@ describe('useQuery', () => { staleTime: Infinity, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2352,7 +2351,7 @@ describe('useQuery', () => { staleTime: 0, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2838,7 +2837,7 @@ describe('useQuery', () => { staleTime: 50, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2893,7 +2892,7 @@ describe('useQuery', () => { initialData: 'initial', })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2935,7 +2934,7 @@ describe('useQuery', () => { initialData: 'initial', })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -2979,7 +2978,7 @@ describe('useQuery', () => { initialDataUpdatedAt: oneSecondAgo, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -3026,7 +3025,7 @@ describe('useQuery', () => { initialDataUpdatedAt: 0, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -3069,7 +3068,7 @@ describe('useQuery', () => { reconcile: false, })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -3342,7 +3341,7 @@ describe('useQuery', () => { queryFn: () => sleep(10).then(() => 'data'), })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -3389,7 +3388,7 @@ describe('useQuery', () => { queryFn: () => sleep(10).then(() => 'data'), })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -3464,7 +3463,7 @@ describe('useQuery', () => { function Page() { const state = useQuery(() => ({ queryKey: key, queryFn })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4123,7 +4122,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4206,7 +4205,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4337,7 +4336,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4459,7 +4458,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4514,7 +4513,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4682,9 +4681,7 @@ describe('useQuery', () => { })) createTrackedEffect(() => { - if (state.isSuccess) { - states.push(state.data) - } + states.push(state.data) }) const forceUpdate = () => { @@ -4836,7 +4833,7 @@ describe('useQuery', () => { const state = useQuery(() => ({ queryKey: [key, id()], queryFn })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4888,7 +4885,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -4968,7 +4965,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -5271,7 +5268,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, @@ -6148,7 +6145,7 @@ describe('useQuery', () => { })) createRenderEffect( - () => ({ ...state }), + () => deep(state), () => { states.push(snapshot(state) as any) }, diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 94e3775f4ee..e9f2561d245 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -3,9 +3,11 @@ // why that happens. import { notifyManager, shouldThrowError } from '@tanstack/query-core' import { + NotReadyError, createRenderEffect, createSignal, createStore, + getObserver, isPending, onCleanup, reconcile, @@ -500,6 +502,25 @@ export function useBaseQuery< throw state.error } + // `data` is typed non-nullable, so a read that happens before the first + // fetch settles has no value to return. Suspend instead: throwing + // NotReadyError from a tracking scope sends the reader to the nearest + // boundary, mirroring the isServer branch above so both sides + // behave the same. The `state` reads here are what re-subscribe the + // reader, so it re-runs once the subscriber syncs the settled result. + // + // Only `isLoading` suspends (pending *and* fetching). A query that is + // pending but idle — disabled, or reset with no observer fetching — has + // nothing in flight to wait for, so it yields undefined rather than + // parking the boundary on a promise that never resolves. + // + // Untracked reads pass through: event handlers and effect callbacks + // peek at the raw value, which keeps imperative access working (and + // lets callers observe pending states) without suspending. + if (prop === 'data' && getObserver() && state.isLoading) { + throw new NotReadyError(observer.getCurrentQuery()) + } + return Reflect.get(target, prop, receiver) }, }) From b9d250d7eee7674c46aaf61193fc3a597d272138 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:39:00 +0000 Subject: [PATCH 5/6] ci: apply automated fixes --- packages/solid-query/src/__tests__/useQuery.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 71fc82b34fa..cc2127ff0a2 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -821,7 +821,11 @@ describe('useQuery', () => { }, })) createRenderEffect( - () => ({ status: state.status, dataUpdatedAt: state.dataUpdatedAt, error: state.error }), + () => ({ + status: state.status, + dataUpdatedAt: state.dataUpdatedAt, + error: state.error, + }), () => { const s = snapshot(state) if (s.status === 'pending') From daa905274fa4879c47de04d04d831ae22821244e Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 25 Aug 2026 19:47:08 -0500 Subject: [PATCH 6/6] fix(solid-query): stand the client suspend down while hydrating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suspending a tracked data read while Solid is claiming server-rendered DOM (sharedConfig.hydrating) bails the claim: the server rendered this content from settled data that the streaming hydration channel may not have primed on the client yet, so the throw leaves unclaimed server nodes and crashes the reactive system with 'Potential Infinite Loop Detected'. Reads during the hydration window return the store value instead — exactly the pre-suspense behavior — and the per-query hydration coordinator re-syncs them once their entry lands. Reproduced in a fullstack streaming-SSR testbed (queries still in flight at hydration time): crashes on every affected load without the guard, zero errors across repeated loads with it. 329/329 package tests pass. Co-Authored-By: Claude Fable 5 --- packages/solid-query/src/useBaseQuery.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index e9f2561d245..1b8d68b58b8 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -13,6 +13,7 @@ import { reconcile, refresh, runWithOwner, + sharedConfig, snapshot, untrack, useContext, @@ -517,7 +518,21 @@ export function useBaseQuery< // Untracked reads pass through: event handlers and effect callbacks // peek at the raw value, which keeps imperative access working (and // lets callers observe pending states) without suspending. - if (prop === 'data' && getObserver() && state.isLoading) { + // + // Hydration stands down: while Solid is claiming server-rendered DOM + // (`sharedConfig.hydrating`), a throw here bails the claim — the + // server rendered this content from settled data that the streaming + // hydration channel may not have primed on the client yet — leaving + // unclaimed server nodes and crashing the reactive system with + // "Potential Infinite Loop Detected". Reads during that window + // return the store value, and the per-query hydration coordinator + // (see hydrationChannel.ts) re-syncs them once their entry lands. + if ( + prop === 'data' && + getObserver() && + state.isLoading && + !sharedConfig.hydrating + ) { throw new NotReadyError(observer.getCurrentQuery()) }