Skip to content

Commit f464aa3

Browse files
joseph0926manudeli
andauthored
[v4] Fix infinite re-renders with synchronous queries in suspense mode (#9584)
Co-authored-by: Jonghyeon Ko <[email protected]>
1 parent 3ef06cf commit f464aa3

File tree

2 files changed

+241
-0
lines changed

2 files changed

+241
-0
lines changed

packages/react-query/src/__tests__/suspense.test.tsx

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,3 +1238,233 @@ describe('useQueries with suspense', () => {
12381238
expect(results).toEqual(['1', '2', 'loading'])
12391239
})
12401240
})
1241+
1242+
describe('cacheTime minimum enforcement with suspense', () => {
1243+
const queryClient = createQueryClient()
1244+
1245+
it('should not cause infinite re-renders with synchronous query function and cacheTime: 0', async () => {
1246+
const key = queryKey()
1247+
let renderCount = 0
1248+
let queryFnCallCount = 0
1249+
const maxChecks = 20
1250+
1251+
function Page() {
1252+
renderCount++
1253+
1254+
if (renderCount > maxChecks) {
1255+
throw new Error(`Infinite loop detected! Renders: ${renderCount}`)
1256+
}
1257+
1258+
const result = useQuery(
1259+
key,
1260+
() => {
1261+
queryFnCallCount++
1262+
return 42
1263+
},
1264+
{
1265+
cacheTime: 0,
1266+
suspense: true,
1267+
},
1268+
)
1269+
1270+
return <div>data: {result.data}</div>
1271+
}
1272+
1273+
const rendered = renderWithClient(
1274+
queryClient,
1275+
<React.Suspense fallback="loading">
1276+
<Page />
1277+
</React.Suspense>,
1278+
)
1279+
1280+
await waitFor(() => rendered.getByText('data: 42'))
1281+
1282+
expect(renderCount).toBeLessThan(5)
1283+
expect(queryFnCallCount).toBe(1)
1284+
expect(rendered.queryByText('data: 42')).not.toBeNull()
1285+
expect(rendered.queryByText('loading')).toBeNull()
1286+
})
1287+
1288+
describe('boundary value tests', () => {
1289+
test.each([
1290+
[0, 1000],
1291+
[1, 1000],
1292+
[999, 1000],
1293+
[1000, 1000],
1294+
[2000, 2000],
1295+
])(
1296+
'cacheTime %i should be adjusted to %i with suspense',
1297+
async (input, expected) => {
1298+
const key = queryKey()
1299+
1300+
function Page() {
1301+
const result = useQuery(key, () => 42, {
1302+
suspense: true,
1303+
cacheTime: input,
1304+
})
1305+
return <div>data: {result.data}</div>
1306+
}
1307+
1308+
const rendered = renderWithClient(
1309+
queryClient,
1310+
<React.Suspense fallback="loading">
1311+
<Page />
1312+
</React.Suspense>,
1313+
)
1314+
1315+
await waitFor(() => rendered.getByText('data: 42'))
1316+
1317+
const query = queryClient.getQueryCache().find(key)
1318+
const options = query?.options
1319+
expect(options?.cacheTime).toBe(expected)
1320+
},
1321+
)
1322+
})
1323+
1324+
it('should preserve user cacheTime when >= 1000ms', async () => {
1325+
const key = queryKey()
1326+
const userCacheTime = 5000
1327+
1328+
function Page() {
1329+
useQuery(key, () => 'test', {
1330+
suspense: true,
1331+
cacheTime: userCacheTime,
1332+
})
1333+
return <div>rendered</div>
1334+
}
1335+
1336+
renderWithClient(
1337+
queryClient,
1338+
<React.Suspense fallback="loading">
1339+
<Page />
1340+
</React.Suspense>,
1341+
)
1342+
1343+
await waitFor(() => {
1344+
const query = queryClient.getQueryCache().find(key)
1345+
const options = query?.options
1346+
expect(options?.cacheTime).toBe(userCacheTime)
1347+
})
1348+
})
1349+
1350+
it('should handle async queries with adjusted cacheTime', async () => {
1351+
const key = queryKey()
1352+
let renderCount = 0
1353+
1354+
function Page() {
1355+
renderCount++
1356+
const result = useQuery(
1357+
key,
1358+
async () => {
1359+
await sleep(10)
1360+
return 'async-result'
1361+
},
1362+
{
1363+
suspense: true,
1364+
cacheTime: 0,
1365+
},
1366+
)
1367+
return <div>data: {result.data}</div>
1368+
}
1369+
1370+
const rendered = renderWithClient(
1371+
queryClient,
1372+
<React.Suspense fallback="loading">
1373+
<Page />
1374+
</React.Suspense>,
1375+
)
1376+
1377+
await waitFor(() => rendered.getByText('data: async-result'))
1378+
expect(renderCount).toBeLessThan(5)
1379+
})
1380+
1381+
describe('staleTime and cacheTime relationship', () => {
1382+
it('should handle when both need adjustment', async () => {
1383+
const key = queryKey()
1384+
1385+
function Page() {
1386+
useQuery(key, () => 42, {
1387+
suspense: true,
1388+
cacheTime: 0,
1389+
staleTime: undefined,
1390+
})
1391+
return <div>rendered</div>
1392+
}
1393+
1394+
renderWithClient(
1395+
queryClient,
1396+
<React.Suspense fallback="loading">
1397+
<Page />
1398+
</React.Suspense>,
1399+
)
1400+
1401+
await waitFor(() => {
1402+
const query = queryClient.getQueryCache().find(key)
1403+
const options = query?.options as any
1404+
expect(options?.cacheTime).toBe(1000)
1405+
expect(options?.staleTime).toBe(1000)
1406+
})
1407+
})
1408+
1409+
it('should maintain staleTime < cacheTime invariant', async () => {
1410+
const key = queryKey()
1411+
1412+
function Page() {
1413+
useQuery(key, () => 42, {
1414+
suspense: true,
1415+
cacheTime: 500,
1416+
staleTime: 2000,
1417+
})
1418+
return <div>rendered</div>
1419+
}
1420+
1421+
renderWithClient(
1422+
queryClient,
1423+
<React.Suspense fallback="loading">
1424+
<Page />
1425+
</React.Suspense>,
1426+
)
1427+
1428+
await waitFor(() => {
1429+
const query = queryClient.getQueryCache().find(key)
1430+
const options = query?.options as any
1431+
expect(options?.cacheTime).toBe(1000)
1432+
expect(options?.staleTime).toBe(2000)
1433+
})
1434+
})
1435+
})
1436+
1437+
it('should fix synchronous query with cacheTime 0 infinite loop', async () => {
1438+
const key = queryKey()
1439+
let renderCount = 0
1440+
let queryFnCallCount = 0
1441+
1442+
function Page() {
1443+
renderCount++
1444+
const result = useQuery(
1445+
key,
1446+
() => {
1447+
queryFnCallCount++
1448+
return 42
1449+
},
1450+
{
1451+
suspense: true,
1452+
cacheTime: 0,
1453+
},
1454+
)
1455+
return <div>data: {result.data}</div>
1456+
}
1457+
1458+
const rendered = renderWithClient(
1459+
queryClient,
1460+
<React.Suspense fallback="loading">
1461+
<Page />
1462+
</React.Suspense>,
1463+
)
1464+
1465+
await waitFor(() => rendered.getByText('data: 42'))
1466+
1467+
expect(renderCount).toBeLessThan(5)
1468+
expect(queryFnCallCount).toBe(1)
1469+
})
1470+
})

packages/react-query/src/suspense.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
44
import type { QueryObserverResult } from '@tanstack/query-core'
55
import type { QueryKey } from '@tanstack/query-core'
66

7+
/**
8+
* Ensures minimum staleTime and cacheTime values when suspense is enabled.
9+
* Despite the name, this function guards both staleTime and cacheTime to prevent
10+
* infinite re-render loops with synchronous queries.
11+
*
12+
* @deprecated in v5 - replaced by ensureSuspenseTimers
13+
*/
714
export const ensureStaleTime = (
815
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
916
) => {
@@ -13,6 +20,10 @@ export const ensureStaleTime = (
1320
if (typeof defaultedOptions.staleTime !== 'number') {
1421
defaultedOptions.staleTime = 1000
1522
}
23+
24+
if (typeof defaultedOptions.cacheTime === 'number') {
25+
defaultedOptions.cacheTime = Math.max(defaultedOptions.cacheTime, 1000)
26+
}
1627
}
1728
}
1829

0 commit comments

Comments
 (0)