From f29d2fff7cb6affe3c5bbf69f42776eecd4174fa Mon Sep 17 00:00:00 2001 From: S-jooyoung Date: Mon, 10 Aug 2026 14:26:30 +0900 Subject: [PATCH] fix({react,preact}-query): add NoInfer to infinite and suspense hook return types Extends the NoInfer fence that useQuery's return types already have to useInfiniteQuery, useSuspenseQuery, and useSuspenseInfiniteQuery, so an explicitly annotated result type can no longer reverse-infer TData. Uses a distributive wrapper (T extends unknown ? NoInfer : never) instead of plain NoInfer so discriminated-union narrowing on data keeps working (see #11018). Fixes #8639 Co-Authored-By: Claude Fable 5 --- .changeset/noinfer-infinite-suspense-hooks.md | 6 +++ .../src/__tests__/useInfiniteQuery.test-d.tsx | 37 +++++++++++++++++++ .../useSuspenseInfiniteQuery.test-d.tsx | 37 +++++++++++++++++++ .../src/__tests__/useSuspenseQuery.test-d.tsx | 31 ++++++++++++++++ packages/preact-query/src/useInfiniteQuery.ts | 8 ++-- .../src/useSuspenseInfiniteQuery.ts | 4 +- packages/preact-query/src/useSuspenseQuery.ts | 4 +- .../src/__tests__/useInfiniteQuery.test-d.tsx | 37 +++++++++++++++++++ .../useSuspenseInfiniteQuery.test-d.tsx | 37 +++++++++++++++++++ .../src/__tests__/useSuspenseQuery.test-d.tsx | 31 ++++++++++++++++ packages/react-query/src/useInfiniteQuery.ts | 8 ++-- .../src/useSuspenseInfiniteQuery.ts | 4 +- packages/react-query/src/useSuspenseQuery.ts | 4 +- 13 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 .changeset/noinfer-infinite-suspense-hooks.md diff --git a/.changeset/noinfer-infinite-suspense-hooks.md b/.changeset/noinfer-infinite-suspense-hooks.md new file mode 100644 index 00000000000..c71d490a517 --- /dev/null +++ b/.changeset/noinfer-infinite-suspense-hooks.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-query': patch +'@tanstack/preact-query': patch +--- + +Add `NoInfer` to the return types of `useInfiniteQuery`, `useSuspenseQuery`, and `useSuspenseInfiniteQuery` so that an explicitly annotated result type can no longer reverse-infer `TData`, matching `useQuery`. A distributive wrapper is used so discriminated-union narrowing on `data` keeps working. diff --git a/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx index b11002c9e2c..b6f39b12ca1 100644 --- a/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx @@ -3,6 +3,7 @@ import type { InfiniteData } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { describe, expectTypeOf, it } from 'vitest' +import type { UseInfiniteQueryResult } from '../types' import { useInfiniteQuery } from '../useInfiniteQuery' describe('pageParam', () => { @@ -142,3 +143,39 @@ describe('error booleans', () => { expectTypeOf(isRefetchError).toEqualTypeOf() }) }) + +describe('NoInfer', () => { + // eslint-disable-next-line vitest/expect-expect + it('TData should depend only on the arguments, not the annotated result', () => { + // @ts-expect-error + const result: UseInfiniteQueryResult> = + useInfiniteQuery({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + initialPageParam: 1, + getNextPageParam: () => undefined, + }) + + void result + }) + + it('should preserve discriminated-union narrowing on data', () => { + type Item = + | { type: 'first'; first: string } + | { type: 'second'; second: string } + + const { data } = useInfiniteQuery({ + queryKey: queryKey(), + queryFn: (): Item => ({ type: 'first', first: 'a' }), + initialPageParam: 1, + getNextPageParam: () => undefined, + select: (infiniteData) => infiniteData.pages[0], + }) + + const second = data?.type === 'first' ? undefined : data + + expectTypeOf(second).toEqualTypeOf< + { type: 'second'; second: string } | undefined + >() + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx index d2b3835de08..db074769d1e 100644 --- a/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx @@ -3,6 +3,7 @@ import type { InfiniteData } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { assertType, describe, expectTypeOf, it } from 'vitest' +import type { UseSuspenseInfiniteQueryResult } from '../types' import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery' describe('useSuspenseInfiniteQuery', () => { @@ -93,3 +94,39 @@ describe('useSuspenseInfiniteQuery', () => { expectTypeOf(query).not.toHaveProperty('isPlaceholderData') }) }) + +describe('NoInfer', () => { + // eslint-disable-next-line vitest/expect-expect + it('TData should depend only on the arguments, not the annotated result', () => { + // @ts-expect-error + const result: UseSuspenseInfiniteQueryResult> = + useSuspenseInfiniteQuery({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + void result + }) + + it('should preserve discriminated-union narrowing on data', () => { + type Item = + | { type: 'first'; first: string } + | { type: 'second'; second: string } + + const { data } = useSuspenseInfiniteQuery({ + queryKey: queryKey(), + queryFn: (): Item => ({ type: 'first', first: 'a' }), + initialPageParam: 1, + getNextPageParam: () => 1, + select: (infiniteData) => infiniteData.pages[0], + }) + + const second = data?.type === 'first' ? undefined : data + + expectTypeOf(second).toEqualTypeOf< + { type: 'second'; second: string } | undefined + >() + }) +}) diff --git a/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx b/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx index 53adae90660..d5ff57bfbaf 100644 --- a/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx +++ b/packages/preact-query/src/__tests__/useSuspenseQuery.test-d.tsx @@ -2,6 +2,7 @@ import { skipToken } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { assertType, describe, expectTypeOf, it } from 'vitest' +import type { UseSuspenseQueryResult } from '../types' import { useSuspenseQuery } from '../useSuspenseQuery' describe('useSuspenseQuery', () => { @@ -88,3 +89,33 @@ describe('useSuspenseQuery', () => { } }) }) + +describe('NoInfer', () => { + // eslint-disable-next-line vitest/expect-expect + it('TData should depend only on the arguments, not the annotated result', () => { + // @ts-expect-error + const result: UseSuspenseQueryResult<{ wow: string }> = useSuspenseQuery({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + }) + + void result + }) + + it('should preserve discriminated-union narrowing on data', () => { + type Result = + | { type: 'first'; first: string } + | { type: 'second'; second: string } + + const { data } = useSuspenseQuery({ + queryKey: queryKey(), + queryFn: (): Result => ({ type: 'first', first: 'a' }), + }) + + const second = data.type === 'first' ? undefined : data + + expectTypeOf(second).toEqualTypeOf< + { type: 'second'; second: string } | undefined + >() + }) +}) diff --git a/packages/preact-query/src/useInfiniteQuery.ts b/packages/preact-query/src/useInfiniteQuery.ts index f78c14a2375..e4f8617b180 100644 --- a/packages/preact-query/src/useInfiniteQuery.ts +++ b/packages/preact-query/src/useInfiniteQuery.ts @@ -18,6 +18,8 @@ import type { } from './types' import { useBaseQuery } from './useBaseQuery' +type NarrowableNoInfer = T extends unknown ? NoInfer : never + export function useInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -33,7 +35,7 @@ export function useInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): DefinedUseInfiniteQueryResult +): DefinedUseInfiniteQueryResult, TError> export function useInfiniteQuery< TQueryFnData, @@ -50,7 +52,7 @@ export function useInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): UseInfiniteQueryResult +): UseInfiniteQueryResult, TError> export function useInfiniteQuery< TQueryFnData, @@ -67,7 +69,7 @@ export function useInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): UseInfiniteQueryResult +): UseInfiniteQueryResult, TError> export function useInfiniteQuery( options: UseInfiniteQueryOptions, diff --git a/packages/preact-query/src/useSuspenseInfiniteQuery.ts b/packages/preact-query/src/useSuspenseInfiniteQuery.ts index 54e52f752dc..39a4bdc2bf6 100644 --- a/packages/preact-query/src/useSuspenseInfiniteQuery.ts +++ b/packages/preact-query/src/useSuspenseInfiniteQuery.ts @@ -15,6 +15,8 @@ import type { } from './types' import { useBaseQuery } from './useBaseQuery' +type NarrowableNoInfer = T extends unknown ? NoInfer : never + export function useSuspenseInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -30,7 +32,7 @@ export function useSuspenseInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): UseSuspenseInfiniteQueryResult { +): UseSuspenseInfiniteQueryResult, TError> { if (process.env.NODE_ENV !== 'production') { if ((options.queryFn as any) === skipToken) { console.error('skipToken is not allowed for useSuspenseInfiniteQuery') diff --git a/packages/preact-query/src/useSuspenseQuery.ts b/packages/preact-query/src/useSuspenseQuery.ts index ad2ff4cca63..951f13ccda6 100644 --- a/packages/preact-query/src/useSuspenseQuery.ts +++ b/packages/preact-query/src/useSuspenseQuery.ts @@ -5,6 +5,8 @@ import { defaultThrowOnError } from './suspense' import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types' import { useBaseQuery } from './useBaseQuery' +type NarrowableNoInfer = T extends unknown ? NoInfer : never + export function useSuspenseQuery< TQueryFnData = unknown, TError = DefaultError, @@ -13,7 +15,7 @@ export function useSuspenseQuery< >( options: UseSuspenseQueryOptions, queryClient?: QueryClient, -): UseSuspenseQueryResult { +): UseSuspenseQueryResult, TError> { if (process.env.NODE_ENV !== 'production') { if ((options.queryFn as any) === skipToken) { console.error('skipToken is not allowed for useSuspenseQuery') diff --git a/packages/react-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/react-query/src/__tests__/useInfiniteQuery.test-d.tsx index b4784aa4b09..6d1dc5432ce 100644 --- a/packages/react-query/src/__tests__/useInfiniteQuery.test-d.tsx +++ b/packages/react-query/src/__tests__/useInfiniteQuery.test-d.tsx @@ -3,6 +3,7 @@ import { QueryClient } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { useInfiniteQuery } from '../useInfiniteQuery' import type { InfiniteData } from '@tanstack/query-core' +import type { UseInfiniteQueryResult } from '../types' describe('pageParam', () => { it('initialPageParam should define type of param passed to queryFunctionContext', () => { @@ -141,3 +142,39 @@ describe('error booleans', () => { expectTypeOf(isRefetchError).toEqualTypeOf() }) }) + +describe('NoInfer', () => { + // eslint-disable-next-line vitest/expect-expect + it('TData should depend only on the arguments, not the annotated result', () => { + // @ts-expect-error + const result: UseInfiniteQueryResult> = + useInfiniteQuery({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + initialPageParam: 1, + getNextPageParam: () => undefined, + }) + + void result + }) + + it('should preserve discriminated-union narrowing on data', () => { + type Item = + | { type: 'first'; first: string } + | { type: 'second'; second: string } + + const { data } = useInfiniteQuery({ + queryKey: queryKey(), + queryFn: (): Item => ({ type: 'first', first: 'a' }), + initialPageParam: 1, + getNextPageParam: () => undefined, + select: (infiniteData) => infiniteData.pages[0], + }) + + const second = data?.type === 'first' ? undefined : data + + expectTypeOf(second).toEqualTypeOf< + { type: 'second'; second: string } | undefined + >() + }) +}) diff --git a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx index e656e8cb50c..7018c9043f1 100644 --- a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx +++ b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.test-d.tsx @@ -3,6 +3,7 @@ import { skipToken } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery' import type { InfiniteData } from '@tanstack/query-core' +import type { UseSuspenseInfiniteQueryResult } from '../types' describe('useSuspenseInfiniteQuery', () => { it('should always have data defined', () => { @@ -92,3 +93,39 @@ describe('useSuspenseInfiniteQuery', () => { expectTypeOf(query).not.toHaveProperty('isPlaceholderData') }) }) + +describe('NoInfer', () => { + // eslint-disable-next-line vitest/expect-expect + it('TData should depend only on the arguments, not the annotated result', () => { + // @ts-expect-error + const result: UseSuspenseInfiniteQueryResult> = + useSuspenseInfiniteQuery({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + initialPageParam: 1, + getNextPageParam: () => 1, + }) + + void result + }) + + it('should preserve discriminated-union narrowing on data', () => { + type Item = + | { type: 'first'; first: string } + | { type: 'second'; second: string } + + const { data } = useSuspenseInfiniteQuery({ + queryKey: queryKey(), + queryFn: (): Item => ({ type: 'first', first: 'a' }), + initialPageParam: 1, + getNextPageParam: () => 1, + select: (infiniteData) => infiniteData.pages[0], + }) + + const second = data?.type === 'first' ? undefined : data + + expectTypeOf(second).toEqualTypeOf< + { type: 'second'; second: string } | undefined + >() + }) +}) diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx index 6e0fb5534bc..acb03d582be 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test-d.tsx @@ -2,6 +2,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { skipToken } from '@tanstack/query-core' import { queryKey } from '@tanstack/query-test-utils' import { useSuspenseQuery } from '../useSuspenseQuery' +import type { UseSuspenseQueryResult } from '../types' describe('useSuspenseQuery', () => { it('should always have data defined', () => { @@ -87,3 +88,33 @@ describe('useSuspenseQuery', () => { } }) }) + +describe('NoInfer', () => { + // eslint-disable-next-line vitest/expect-expect + it('TData should depend only on the arguments, not the annotated result', () => { + // @ts-expect-error + const result: UseSuspenseQueryResult<{ wow: string }> = useSuspenseQuery({ + queryKey: queryKey(), + queryFn: () => ({ wow: true }), + }) + + void result + }) + + it('should preserve discriminated-union narrowing on data', () => { + type Result = + | { type: 'first'; first: string } + | { type: 'second'; second: string } + + const { data } = useSuspenseQuery({ + queryKey: queryKey(), + queryFn: (): Result => ({ type: 'first', first: 'a' }), + }) + + const second = data.type === 'first' ? undefined : data + + expectTypeOf(second).toEqualTypeOf< + { type: 'second'; second: string } | undefined + >() + }) +}) diff --git a/packages/react-query/src/useInfiniteQuery.ts b/packages/react-query/src/useInfiniteQuery.ts index 32ebfb7673e..4074880eecd 100644 --- a/packages/react-query/src/useInfiniteQuery.ts +++ b/packages/react-query/src/useInfiniteQuery.ts @@ -18,6 +18,8 @@ import type { UndefinedInitialDataInfiniteOptions, } from './infiniteQueryOptions' +type NarrowableNoInfer = T extends unknown ? NoInfer : never + export function useInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -33,7 +35,7 @@ export function useInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): DefinedUseInfiniteQueryResult +): DefinedUseInfiniteQueryResult, TError> export function useInfiniteQuery< TQueryFnData, @@ -50,7 +52,7 @@ export function useInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): UseInfiniteQueryResult +): UseInfiniteQueryResult, TError> export function useInfiniteQuery< TQueryFnData, @@ -67,7 +69,7 @@ export function useInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): UseInfiniteQueryResult +): UseInfiniteQueryResult, TError> export function useInfiniteQuery( options: UseInfiniteQueryOptions, diff --git a/packages/react-query/src/useSuspenseInfiniteQuery.ts b/packages/react-query/src/useSuspenseInfiniteQuery.ts index 3c2fdfdd72f..38ab779c4b1 100644 --- a/packages/react-query/src/useSuspenseInfiniteQuery.ts +++ b/packages/react-query/src/useSuspenseInfiniteQuery.ts @@ -15,6 +15,8 @@ import type { UseSuspenseInfiniteQueryResult, } from './types' +type NarrowableNoInfer = T extends unknown ? NoInfer : never + export function useSuspenseInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -30,7 +32,7 @@ export function useSuspenseInfiniteQuery< TPageParam >, queryClient?: QueryClient, -): UseSuspenseInfiniteQueryResult { +): UseSuspenseInfiniteQueryResult, TError> { if (process.env.NODE_ENV !== 'production') { if ((options.queryFn as any) === skipToken) { console.error('skipToken is not allowed for useSuspenseInfiniteQuery') diff --git a/packages/react-query/src/useSuspenseQuery.ts b/packages/react-query/src/useSuspenseQuery.ts index 7dfdb064778..c8aeab172d6 100644 --- a/packages/react-query/src/useSuspenseQuery.ts +++ b/packages/react-query/src/useSuspenseQuery.ts @@ -5,6 +5,8 @@ import { defaultThrowOnError } from './suspense' import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types' import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core' +type NarrowableNoInfer = T extends unknown ? NoInfer : never + export function useSuspenseQuery< TQueryFnData = unknown, TError = DefaultError, @@ -13,7 +15,7 @@ export function useSuspenseQuery< >( options: UseSuspenseQueryOptions, queryClient?: QueryClient, -): UseSuspenseQueryResult { +): UseSuspenseQueryResult, TError> { if (process.env.NODE_ENV !== 'production') { if ((options.queryFn as any) === skipToken) { console.error('skipToken is not allowed for useSuspenseQuery')