From ed2a35b8d73f748e16e77c73a6fe75295aafa245 Mon Sep 17 00:00:00 2001 From: Jonghyeon Ko Date: Tue, 9 Sep 2025 16:03:12 +0900 Subject: [PATCH 1/2] feat(react-query): introduce useSuspenseInfiniteQuery and infiniteQueryOptions for enhanced infinite query handling - Added `useSuspenseInfiniteQuery` hook for improved suspense handling with infinite queries. - Introduced `infiniteQueryOptions` function to streamline infinite query option management. - Updated types to include `DefinedInfiniteQueryObserverResult` and related types for better type safety. - Refactored existing query options to accommodate new infinite query features. - Added tests for new functionalities to ensure correctness and type safety. --- packages/query-core/src/types.ts | 10 +- .../__tests__/infiniteQueryOptions.test.tsx | 13 ++ .../infiniteQueryOptions.types.test.tsx | 111 +++++++++++++++++ .../src/__tests__/queryOptions.types.test.tsx | 15 +-- .../useSuspenseInfiniteQuery.types.test.tsx | 115 ++++++++++++++++++ packages/react-query/src/index.ts | 6 + .../react-query/src/infiniteQueryOptions.ts | 95 +++++++++++++++ packages/react-query/src/queryOptions.ts | 10 +- packages/react-query/src/types.ts | 14 +++ packages/react-query/src/useInfiniteQuery.ts | 29 ++++- packages/react-query/src/useQuery.ts | 12 +- .../src/useSuspenseInfiniteQuery.ts | 62 ++++++++++ 12 files changed, 474 insertions(+), 18 deletions(-) create mode 100644 packages/react-query/src/__tests__/infiniteQueryOptions.test.tsx create mode 100644 packages/react-query/src/__tests__/infiniteQueryOptions.types.test.tsx create mode 100644 packages/react-query/src/__tests__/useSuspenseInfiniteQuery.types.test.tsx create mode 100644 packages/react-query/src/infiniteQueryOptions.ts create mode 100644 packages/react-query/src/useSuspenseInfiniteQuery.ts diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 019f235ed7..efb7490dd8 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -568,11 +568,17 @@ export interface InfiniteQueryObserverSuccessResult< status: 'success' } +export type DefinedInfiniteQueryObserverResult< + TData = unknown, + TError = unknown, +> = + | InfiniteQueryObserverRefetchErrorResult + | InfiniteQueryObserverSuccessResult + export type InfiniteQueryObserverResult = + | DefinedInfiniteQueryObserverResult | InfiniteQueryObserverLoadingErrorResult | InfiniteQueryObserverLoadingResult - | InfiniteQueryObserverRefetchErrorResult - | InfiniteQueryObserverSuccessResult export type MutationKey = readonly unknown[] diff --git a/packages/react-query/src/__tests__/infiniteQueryOptions.test.tsx b/packages/react-query/src/__tests__/infiniteQueryOptions.test.tsx new file mode 100644 index 0000000000..eb12e144b9 --- /dev/null +++ b/packages/react-query/src/__tests__/infiniteQueryOptions.test.tsx @@ -0,0 +1,13 @@ +import { infiniteQueryOptions } from '../infiniteQueryOptions' + +describe('infiniteQueryOptions', () => { + it('should return the object received as a parameter without any modification.', () => { + const object = { + queryKey: ['key'], + queryFn: () => Promise.resolve(5), + getNextPageParam: () => null, + } as const + + expect(infiniteQueryOptions(object)).toStrictEqual(object) + }) +}) diff --git a/packages/react-query/src/__tests__/infiniteQueryOptions.types.test.tsx b/packages/react-query/src/__tests__/infiniteQueryOptions.types.test.tsx new file mode 100644 index 0000000000..05c973bd3c --- /dev/null +++ b/packages/react-query/src/__tests__/infiniteQueryOptions.types.test.tsx @@ -0,0 +1,111 @@ +import { expectTypeOf } from 'expect-type' +import { + type InfiniteData, + type UseInfiniteQueryResult, + useInfiniteQuery, + useQueryClient, +} from '@tanstack/react-query' + +import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery' +import { infiniteQueryOptions } from '../infiniteQueryOptions' +import { doNotExecute } from './utils' +import type { + DefinedUseInfiniteQueryResult, + UseSuspenseInfiniteQueryResult, +} from '../types' + +const infiniteQuery = { + options: () => + infiniteQueryOptions({ + queryKey: ['key', 1] as const, + queryFn: () => Promise.resolve({ field: 'success' }), + }), + optionsWithInitialData: () => + infiniteQueryOptions({ + queryKey: ['key', 2] as const, + queryFn: () => Promise.resolve({ field: 'success' }), + initialData: () => ({ pageParams: [], pages: [{ field: 'success' }] }), + }), +} + +describe('infiniteQueryOptions', () => { + it('should be used with useInfiniteQuery', () => { + doNotExecute(() => { + expectTypeOf(useInfiniteQuery(infiniteQuery.options())).toEqualTypeOf< + UseInfiniteQueryResult<{ field: string }> + >() + + expectTypeOf( + useInfiniteQuery({ + ...infiniteQuery.options(), + select: (data) => ({ + pages: data.pages.map(({ field }) => field), + pageParams: data.pageParams, + }), + }), + ).toEqualTypeOf>() + + expectTypeOf( + useInfiniteQuery(infiniteQuery.optionsWithInitialData()), + ).toEqualTypeOf>() + + expectTypeOf( + useInfiniteQuery({ + ...infiniteQuery.optionsWithInitialData(), + select: (data) => ({ + pages: data.pages.map(({ field }) => field), + pageParams: data.pageParams, + }), + }), + ).toEqualTypeOf>() + + expectTypeOf( + useInfiniteQuery({ + queryKey: ['key', 2] as const, + queryFn: () => Promise.resolve({ field: 'success' }), + initialData: () => ({ + pages: [{ field: 'success' }], + pageParams: [], + }), + select: (data) => ({ + pages: data.pages.map(({ field }) => field), + pageParams: data.pageParams, + }), + }), + ).toEqualTypeOf>() + }) + }) + it('should be used with useSuspenseInfiniteQuery', () => { + doNotExecute(() => { + expectTypeOf( + useSuspenseInfiniteQuery(infiniteQuery.options()), + ).toEqualTypeOf>() + + expectTypeOf( + useSuspenseInfiniteQuery({ + ...infiniteQuery.options(), + select: (data) => ({ + pages: data.pages.map(({ field }) => field), + pageParams: data.pageParams, + }), + }), + ).toEqualTypeOf>() + }) + }) + it('should be used with useQueryClient', () => { + doNotExecute(async () => { + const queryClient = useQueryClient() + + queryClient.invalidateQueries(infiniteQuery.options()) + queryClient.resetQueries(infiniteQuery.options()) + queryClient.removeQueries(infiniteQuery.options()) + queryClient.cancelQueries(infiniteQuery.options()) + queryClient.prefetchQuery(infiniteQuery.options()) + queryClient.refetchQueries(infiniteQuery.options()) + + expectTypeOf( + await queryClient.fetchQuery(infiniteQuery.options()), + ).toEqualTypeOf>() + }) + }) +}) diff --git a/packages/react-query/src/__tests__/queryOptions.types.test.tsx b/packages/react-query/src/__tests__/queryOptions.types.test.tsx index a8d435b6d2..9bea844aad 100644 --- a/packages/react-query/src/__tests__/queryOptions.types.test.tsx +++ b/packages/react-query/src/__tests__/queryOptions.types.test.tsx @@ -19,13 +19,14 @@ const queryFn = () => Promise.resolve({ field: 'success' }) describe('queryOptions', () => { it('should be used with useQuery', () => { doNotExecute(() => { - const dd = useQuery( - queryOptions({ - queryKey, - queryFn, - }), - ) - expectTypeOf(dd).toEqualTypeOf>() + expectTypeOf( + useQuery( + queryOptions({ + queryKey, + queryFn, + }), + ), + ).toEqualTypeOf>() expectTypeOf( useQuery({ ...queryOptions({ diff --git a/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.types.test.tsx b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.types.test.tsx new file mode 100644 index 0000000000..7e486af5dd --- /dev/null +++ b/packages/react-query/src/__tests__/useSuspenseInfiniteQuery.types.test.tsx @@ -0,0 +1,115 @@ +import { expectTypeOf } from 'expect-type' +import { infiniteQueryOptions, useSuspenseInfiniteQuery } from '..' +import { doNotExecute, sleep } from './utils' +import type { UseSuspenseInfiniteQueryResult } from '..' + +import type { InfiniteData } from '@tanstack/react-query' + +const queryKey = ['key'] as const +const queryFn = () => sleep(10).then(() => ({ text: 'response' })) + +describe('useSuspenseInfiniteQuery', () => { + it('type check', () => { + doNotExecute(() => { + // @ts-expect-error no arg + useSuspenseInfiniteQuery() + + useSuspenseInfiniteQuery({ + queryKey, + queryFn, + // @ts-expect-error no suspense + suspense: boolean, + }) + useSuspenseInfiniteQuery({ + queryKey, + queryFn, + // @ts-expect-error no useErrorBoundary + useErrorBoundary: boolean, + }) + useSuspenseInfiniteQuery({ + queryKey, + queryFn, + // @ts-expect-error no enabled + enabled: boolean, + }) + useSuspenseInfiniteQuery({ + queryKey, + queryFn, + // @ts-expect-error no placeholderData + placeholderData: 'placeholder', + }) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + useSuspenseInfiniteQuery({ + queryKey, + queryFn, + // @ts-expect-error no isPlaceholderData + }).isPlaceholderData + useSuspenseInfiniteQuery({ + queryKey, + queryFn, + //@ts-expect-error no networkMode + networkMode: 'always', + }) + + const infiniteQuery = useSuspenseInfiniteQuery({ queryKey, queryFn }) + expectTypeOf(infiniteQuery).toEqualTypeOf< + UseSuspenseInfiniteQueryResult<{ text: string }> + >() + expectTypeOf(infiniteQuery.data).toEqualTypeOf< + InfiniteData<{ text: string }> + >() + expectTypeOf(infiniteQuery.status).toEqualTypeOf<'error' | 'success'>() + + const selectedInfiniteQuery = useSuspenseInfiniteQuery({ + queryKey, + queryFn, + select: (data) => ({ + pages: data.pages.map(({ text }) => text), + pageParams: data.pageParams, + }), + }) + expectTypeOf(selectedInfiniteQuery).toEqualTypeOf< + UseSuspenseInfiniteQueryResult + >() + expectTypeOf(selectedInfiniteQuery.data).toEqualTypeOf< + InfiniteData + >() + expectTypeOf(selectedInfiniteQuery.status).toEqualTypeOf< + 'error' | 'success' + >() + + const options = infiniteQueryOptions({ + queryKey, + queryFn, + }) + + const infiniteQueryWithOptions = useSuspenseInfiniteQuery(options) + expectTypeOf(infiniteQueryWithOptions).toEqualTypeOf< + UseSuspenseInfiniteQueryResult<{ text: string }> + >() + expectTypeOf(infiniteQueryWithOptions.data).toEqualTypeOf< + InfiniteData<{ text: string }> + >() + expectTypeOf(infiniteQueryWithOptions.status).toEqualTypeOf< + 'error' | 'success' + >() + + const selectedInfiniteQueryWithOptions = useSuspenseInfiniteQuery({ + ...options, + select: (data) => ({ + pages: data.pages.map(({ text }) => text), + pageParams: data.pageParams, + }), + }) + expectTypeOf(selectedInfiniteQueryWithOptions).toEqualTypeOf< + UseSuspenseInfiniteQueryResult + >() + expectTypeOf(selectedInfiniteQueryWithOptions.data).toEqualTypeOf< + InfiniteData + >() + expectTypeOf(selectedInfiniteQueryWithOptions.status).toEqualTypeOf< + 'error' | 'success' + >() + }) + }) +}) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index d08e593cd7..b4fbe8b403 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -12,6 +12,7 @@ export { useQueries } from './useQueries' export type { QueriesResults, QueriesOptions } from './useQueries' export { useQuery } from './useQuery' export { useSuspenseQuery } from './useSuspenseQuery' +export { useSuspenseInfiniteQuery } from './useSuspenseInfiniteQuery' export { useSuspenseQueries } from './useSuspenseQueries' export type { SuspenseQueriesResults, @@ -22,6 +23,11 @@ export type { DefinedInitialDataOptions, UndefinedInitialDataOptions, } from './queryOptions' +export { infiniteQueryOptions } from './infiniteQueryOptions' +export type { + DefinedInitialDataInfiniteOptions, + UndefinedInitialDataInfiniteOptions, +} from './infiniteQueryOptions' export { defaultContext, QueryClientProvider, diff --git a/packages/react-query/src/infiniteQueryOptions.ts b/packages/react-query/src/infiniteQueryOptions.ts new file mode 100644 index 0000000000..afafe35a8b --- /dev/null +++ b/packages/react-query/src/infiniteQueryOptions.ts @@ -0,0 +1,95 @@ +import type { UseInfiniteQueryOptions } from './types' +import type { + InfiniteData, + NonUndefinedGuard, + OmitKeyof, + QueryKey, + WithRequired, +} from '@tanstack/query-core' + +type UseInfiniteQueryOptionsOmitted< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = OmitKeyof< + UseInfiniteQueryOptions, + 'onSuccess' | 'onError' | 'onSettled' | 'refetchInterval' +> + +type ProhibitedInfiniteQueryOptionsKeyInV5 = keyof Pick< + UseInfiniteQueryOptionsOmitted, + 'useErrorBoundary' | 'suspense' +> + +export type UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = UseInfiniteQueryOptionsOmitted & { + initialData?: undefined +} + +export type DefinedInitialDataInfiniteOptions< + TQueryFnData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = UseInfiniteQueryOptionsOmitted & { + initialData: + | NonUndefinedGuard> + | (() => NonUndefinedGuard>) + | undefined +} + +export function infiniteQueryOptions< + TQueryFnData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: WithRequired< + OmitKeyof< + DefinedInitialDataInfiniteOptions, + ProhibitedInfiniteQueryOptionsKeyInV5 + >, + 'queryKey' + >, +): WithRequired< + OmitKeyof< + DefinedInitialDataInfiniteOptions, + ProhibitedInfiniteQueryOptionsKeyInV5 + >, + 'queryKey' +> + +export function infiniteQueryOptions< + TQueryFnData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: WithRequired< + OmitKeyof< + UndefinedInitialDataInfiniteOptions< + TQueryFnData, + TError, + TData, + TQueryKey + >, + ProhibitedInfiniteQueryOptionsKeyInV5 + >, + 'queryKey' + >, +): WithRequired< + OmitKeyof< + UndefinedInitialDataInfiniteOptions, + ProhibitedInfiniteQueryOptionsKeyInV5 + >, + 'queryKey' +> + +export function infiniteQueryOptions(options: unknown) { + return options +} diff --git a/packages/react-query/src/queryOptions.ts b/packages/react-query/src/queryOptions.ts index 730eafb2a7..18a81211a3 100644 --- a/packages/react-query/src/queryOptions.ts +++ b/packages/react-query/src/queryOptions.ts @@ -59,7 +59,10 @@ export function queryOptions< 'queryKey' >, ): WithRequired< - DefinedInitialDataOptions, + OmitKeyof< + DefinedInitialDataOptions, + ProhibitedQueryOptionsKeyInV5 + >, 'queryKey' > @@ -77,7 +80,10 @@ export function queryOptions< 'queryKey' >, ): WithRequired< - UndefinedInitialDataOptions, + OmitKeyof< + UndefinedInitialDataOptions, + ProhibitedQueryOptionsKeyInV5 + >, 'queryKey' > diff --git a/packages/react-query/src/types.ts b/packages/react-query/src/types.ts index e705450f1f..4ff54ff26b 100644 --- a/packages/react-query/src/types.ts +++ b/packages/react-query/src/types.ts @@ -2,6 +2,7 @@ import type * as React from 'react' import type { + DefinedInfiniteQueryObserverResult, DefinedQueryObserverResult, DistributiveOmit, InfiniteQueryObserverOptions, @@ -120,6 +121,19 @@ export type UseInfiniteQueryResult< TError = unknown, > = InfiniteQueryObserverResult +export type DefinedUseInfiniteQueryResult< + TData = unknown, + TError = unknown, +> = DefinedInfiniteQueryObserverResult + +export type UseSuspenseInfiniteQueryResult< + TData = unknown, + TError = unknown, +> = OmitKeyof< + DefinedInfiniteQueryObserverResult, + 'isPlaceholderData' +> + export interface UseMutationOptions< TData = unknown, TError = unknown, diff --git a/packages/react-query/src/useInfiniteQuery.ts b/packages/react-query/src/useInfiniteQuery.ts index cffc36f0dc..37cbe20848 100644 --- a/packages/react-query/src/useInfiniteQuery.ts +++ b/packages/react-query/src/useInfiniteQuery.ts @@ -2,14 +2,39 @@ import { InfiniteQueryObserver, parseQueryArgs } from '@tanstack/query-core' import { useBaseQuery } from './useBaseQuery' import type { + InfiniteData, + NonUndefinedGuard, QueryFunction, QueryKey, QueryObserver, } from '@tanstack/query-core' -import type { UseInfiniteQueryOptions, UseInfiniteQueryResult } from './types' +import type { + DefinedUseInfiniteQueryResult, + UseInfiniteQueryOptions, + UseInfiniteQueryResult, +} from './types' // HOOK +export function useInfiniteQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + > & { + initialData: + | NonUndefinedGuard> + | (() => NonUndefinedGuard>) + | undefined + }, +): DefinedUseInfiniteQueryResult export function useInfiniteQuery< TQueryFnData = unknown, TError = unknown, @@ -24,6 +49,7 @@ export function useInfiniteQuery< TQueryKey >, ): UseInfiniteQueryResult +/** @deprecated This function overload will be removed in the next major version. */ export function useInfiniteQuery< TQueryFnData = unknown, TError = unknown, @@ -42,6 +68,7 @@ export function useInfiniteQuery< 'queryKey' >, ): UseInfiniteQueryResult +/** @deprecated This function overload will be removed in the next major version. */ export function useInfiniteQuery< TQueryFnData = unknown, TError = unknown, diff --git a/packages/react-query/src/useQuery.ts b/packages/react-query/src/useQuery.ts index 44100cc918..977e300824 100644 --- a/packages/react-query/src/useQuery.ts +++ b/packages/react-query/src/useQuery.ts @@ -55,7 +55,7 @@ export function useQuery< options: UseQueryOptions, ): UseQueryResult -/** @deprecated */ +/** @deprecated This function overload will be removed in the next major version. */ export function useQuery< TQueryFnData = unknown, TError = unknown, @@ -68,7 +68,7 @@ export function useQuery< 'queryKey' | 'initialData' >, ): UseQueryResult -/** @deprecated */ +/** @deprecated This function overload will be removed in the next major version. */ export function useQuery< TQueryFnData = unknown, TError = unknown, @@ -81,7 +81,7 @@ export function useQuery< 'queryKey' | 'initialData' > & { initialData: TQueryFnData | (() => TQueryFnData) }, ): DefinedUseQueryResult -/** @deprecated */ +/** @deprecated This function overload will be removed in the next major version. */ export function useQuery< TQueryFnData = unknown, TError = unknown, @@ -94,7 +94,7 @@ export function useQuery< 'queryKey' >, ): UseQueryResult -/** @deprecated */ +/** @deprecated This function overload will be removed in the next major version. */ export function useQuery< TQueryFnData = unknown, TError = unknown, @@ -108,7 +108,7 @@ export function useQuery< 'queryKey' | 'queryFn' | 'initialData' > & { initialData?: () => undefined }, ): UseQueryResult -/** @deprecated */ +/** @deprecated This function overload will be removed in the next major version. */ export function useQuery< TQueryFnData = unknown, TError = unknown, @@ -122,7 +122,7 @@ export function useQuery< 'queryKey' | 'queryFn' | 'initialData' > & { initialData: TQueryFnData | (() => TQueryFnData) }, ): DefinedUseQueryResult -/** @deprecated */ +/** @deprecated This function overload will be removed in the next major version. */ export function useQuery< TQueryFnData = unknown, TError = unknown, diff --git a/packages/react-query/src/useSuspenseInfiniteQuery.ts b/packages/react-query/src/useSuspenseInfiniteQuery.ts new file mode 100644 index 0000000000..251407c8e3 --- /dev/null +++ b/packages/react-query/src/useSuspenseInfiniteQuery.ts @@ -0,0 +1,62 @@ +import { InfiniteQueryObserver } from '@tanstack/query-core' +import { useBaseQuery } from './useBaseQuery' +import type { + InfiniteQueryObserverSuccessResult, + OmitKeyof, + QueryKey, + QueryObserver, + WithRequired, +} from '@tanstack/query-core' +import type { + UseInfiniteQueryOptions, + UseSuspenseInfiniteQueryResult, +} from './types' + +export type UseSuspenseInfiniteQueryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = WithRequired< + OmitKeyof< + UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + | 'suspense' + | 'useErrorBoundary' + | 'enabled' + | 'placeholderData' + | 'networkMode' + | 'initialData' + >, + 'queryKey' +> + +export function useSuspenseInfiniteQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UseSuspenseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryKey + >, +): UseSuspenseInfiniteQueryResult { + return useBaseQuery( + { + ...options, + enabled: true, + suspense: true, + useErrorBoundary: true, + networkMode: 'always', + }, + InfiniteQueryObserver as typeof QueryObserver, + ) as InfiniteQueryObserverSuccessResult +} From 32d351662e16dce156ab600a24e247934736ce76 Mon Sep 17 00:00:00 2001 From: Jonghyeon Ko Date: Tue, 9 Sep 2025 16:08:22 +0900 Subject: [PATCH 2/2] chore: update