From a510da6b1cf7e1048e53af0b54a29c20ba3e0be2 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Thu, 21 Aug 2025 11:04:19 +0900 Subject: [PATCH 1/4] fix(react-query): enforce minimum cacheTime in suspense mode to prevent infinite loops --- .../src/__tests__/suspense.test.tsx | 250 +++++++++++++++++- packages/react-query/src/suspense.ts | 11 + 2 files changed, 260 insertions(+), 1 deletion(-) diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index 52ab4d6c4e..362a1d7757 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -10,7 +10,11 @@ import { useQueryErrorResetBoundary, } from '..' import { createQueryClient, queryKey, renderWithClient, sleep } from './utils' -import type { UseInfiniteQueryResult, UseQueryResult } from '..' +import type { + QueryObserverOptions, + UseInfiniteQueryResult, + UseQueryResult, +} from '..' describe("useQuery's in Suspense mode", () => { const queryCache = new QueryCache() @@ -1238,3 +1242,247 @@ 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++ + console.log(`Render #${renderCount}`) + + if (renderCount > maxChecks) { + throw new Error(`Infinite loop detected! Renders: ${renderCount}`) + } + + const result = useQuery( + key, + () => { + queryFnCallCount++ + console.log(`Query function call #${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 QueryObserverOptions< + any, + any, + any, + any, + 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 QueryObserverOptions< + any, + any, + any, + any, + 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) + } } } From eea6cbceed9ba9daf1f08484f80969da69f99544 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Thu, 21 Aug 2025 11:46:57 +0900 Subject: [PATCH 2/4] fix(react-query): enforce minimum cacheTime in suspense mode to prevent infinite loops - fix test --- .../src/__tests__/suspense.test.tsx | 61 +------------------ 1 file changed, 2 insertions(+), 59 deletions(-) diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index 362a1d7757..64bf6099ca 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -1246,51 +1246,6 @@ describe('useQueries with suspense', () => { 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++ - console.log(`Render #${renderCount}`) - - if (renderCount > maxChecks) { - throw new Error(`Infinite loop detected! Renders: ${renderCount}`) - } - - const result = useQuery( - key, - () => { - queryFnCallCount++ - console.log(`Query function call #${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], @@ -1406,13 +1361,7 @@ describe('cacheTime minimum enforcement with suspense', () => { await waitFor(() => { const query = queryClient.getQueryCache().find(key) - const options = query?.options as QueryObserverOptions< - any, - any, - any, - any, - any - > + const options = query?.options as any expect(options?.cacheTime).toBe(1000) expect(options?.staleTime).toBe(1000) }) @@ -1439,13 +1388,7 @@ describe('cacheTime minimum enforcement with suspense', () => { await waitFor(() => { const query = queryClient.getQueryCache().find(key) - const options = query?.options as QueryObserverOptions< - any, - any, - any, - any, - any - > + const options = query?.options as any expect(options?.cacheTime).toBe(1000) expect(options?.staleTime).toBe(2000) }) From 5d2ab62e847d470f93060e6ad06ba07def443f4b Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Thu, 21 Aug 2025 11:50:16 +0900 Subject: [PATCH 3/4] fix(react-query): enforce minimum cacheTime in suspense mode to prevent infinite loops - fix test --- packages/react-query/src/__tests__/suspense.test.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index 64bf6099ca..c573c51dc3 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -10,11 +10,7 @@ import { useQueryErrorResetBoundary, } from '..' import { createQueryClient, queryKey, renderWithClient, sleep } from './utils' -import type { - QueryObserverOptions, - UseInfiniteQueryResult, - UseQueryResult, -} from '..' +import type { UseInfiniteQueryResult, UseQueryResult } from '..' describe("useQuery's in Suspense mode", () => { const queryCache = new QueryCache() From 84f568a1fea4ee4fd365da557435e4a3ca203c24 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Mon, 1 Sep 2025 21:01:40 +0900 Subject: [PATCH 4/4] test(react-query): restore infinite re-render prevention test for suspense mode --- .../src/__tests__/suspense.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index c573c51dc3..cc1d029697 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -1242,6 +1242,49 @@ describe('useQueries with suspense', () => { 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],