diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index 52ab4d6c4e..cc1d029697 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -1238,3 +1238,233 @@ describe('useQueries with suspense', () => { expect(results).toEqual(['1', '2', 'loading']) }) }) + +describe('cacheTime minimum enforcement with suspense', () => { + const queryClient = createQueryClient() + + it('should not cause infinite re-renders with synchronous query function and cacheTime: 0', async () => { + const key = queryKey() + let renderCount = 0 + let queryFnCallCount = 0 + const maxChecks = 20 + + function Page() { + renderCount++ + + if (renderCount > maxChecks) { + throw new Error(`Infinite loop detected! Renders: ${renderCount}`) + } + + const result = useQuery( + key, + () => { + queryFnCallCount++ + return 42 + }, + { + cacheTime: 0, + suspense: true, + }, + ) + + return
data: {result.data}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await waitFor(() => rendered.getByText('data: 42')) + + expect(renderCount).toBeLessThan(5) + expect(queryFnCallCount).toBe(1) + expect(rendered.queryByText('data: 42')).not.toBeNull() + expect(rendered.queryByText('loading')).toBeNull() + }) + + describe('boundary value tests', () => { + test.each([ + [0, 1000], + [1, 1000], + [999, 1000], + [1000, 1000], + [2000, 2000], + ])( + 'cacheTime %i should be adjusted to %i with suspense', + async (input, expected) => { + const key = queryKey() + + function Page() { + const result = useQuery(key, () => 42, { + suspense: true, + cacheTime: input, + }) + return
data: {result.data}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await waitFor(() => rendered.getByText('data: 42')) + + const query = queryClient.getQueryCache().find(key) + const options = query?.options + expect(options?.cacheTime).toBe(expected) + }, + ) + }) + + it('should preserve user cacheTime when >= 1000ms', async () => { + const key = queryKey() + const userCacheTime = 5000 + + function Page() { + useQuery(key, () => 'test', { + suspense: true, + cacheTime: userCacheTime, + }) + return
rendered
+ } + + renderWithClient( + queryClient, + + + , + ) + + await waitFor(() => { + const query = queryClient.getQueryCache().find(key) + const options = query?.options + expect(options?.cacheTime).toBe(userCacheTime) + }) + }) + + it('should handle async queries with adjusted cacheTime', async () => { + const key = queryKey() + let renderCount = 0 + + function Page() { + renderCount++ + const result = useQuery( + key, + async () => { + await sleep(10) + return 'async-result' + }, + { + suspense: true, + cacheTime: 0, + }, + ) + return
data: {result.data}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await waitFor(() => rendered.getByText('data: async-result')) + expect(renderCount).toBeLessThan(5) + }) + + describe('staleTime and cacheTime relationship', () => { + it('should handle when both need adjustment', async () => { + const key = queryKey() + + function Page() { + useQuery(key, () => 42, { + suspense: true, + cacheTime: 0, + staleTime: undefined, + }) + return
rendered
+ } + + renderWithClient( + queryClient, + + + , + ) + + await waitFor(() => { + const query = queryClient.getQueryCache().find(key) + const options = query?.options as any + expect(options?.cacheTime).toBe(1000) + expect(options?.staleTime).toBe(1000) + }) + }) + + it('should maintain staleTime < cacheTime invariant', async () => { + const key = queryKey() + + function Page() { + useQuery(key, () => 42, { + suspense: true, + cacheTime: 500, + staleTime: 2000, + }) + return
rendered
+ } + + renderWithClient( + queryClient, + + + , + ) + + await waitFor(() => { + const query = queryClient.getQueryCache().find(key) + const options = query?.options as any + expect(options?.cacheTime).toBe(1000) + expect(options?.staleTime).toBe(2000) + }) + }) + }) + + it('should fix synchronous query with cacheTime 0 infinite loop', async () => { + const key = queryKey() + let renderCount = 0 + let queryFnCallCount = 0 + + function Page() { + renderCount++ + const result = useQuery( + key, + () => { + queryFnCallCount++ + return 42 + }, + { + suspense: true, + cacheTime: 0, + }, + ) + return
data: {result.data}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await waitFor(() => rendered.getByText('data: 42')) + + expect(renderCount).toBeLessThan(5) + expect(queryFnCallCount).toBe(1) + }) +}) diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index 682409e75d..56afdae1f1 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -4,6 +4,13 @@ import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' import type { QueryObserverResult } from '@tanstack/query-core' import type { QueryKey } from '@tanstack/query-core' +/** + * Ensures minimum staleTime and cacheTime values when suspense is enabled. + * Despite the name, this function guards both staleTime and cacheTime to prevent + * infinite re-render loops with synchronous queries. + * + * @deprecated in v5 - replaced by ensureSuspenseTimers + */ export const ensureStaleTime = ( defaultedOptions: DefaultedQueryObserverOptions, ) => { @@ -13,6 +20,10 @@ export const ensureStaleTime = ( if (typeof defaultedOptions.staleTime !== 'number') { defaultedOptions.staleTime = 1000 } + + if (typeof defaultedOptions.cacheTime === 'number') { + defaultedOptions.cacheTime = Math.max(defaultedOptions.cacheTime, 1000) + } } }