Skip to content

Commit d504d4d

Browse files
fix(query-core): release the retryer once a fetch settles
A query kept its last retryer for life, and the settled promise holds that fetch's raw result: a second full copy of the data once a refetch has been structurally shared into state.data, and still held after reset() or removal.
1 parent 46d7f02 commit d504d4d

3 files changed

Lines changed: 44 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/query-core': patch
3+
---
4+
5+
Release a query's retryer once its fetch settles, so the settled promise no longer keeps that fetch's raw result in memory alongside the structurally shared `state.data` (or after the query is reset or removed).

packages/query-core/src/__tests__/query.test.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,15 @@ import {
77
import {
88
CancelledError,
99
Query,
10+
QueryCache,
1011
QueryClient,
1112
QueryObserver,
1213
dehydrate,
1314
hydrate,
1415
} from '..'
1516
import { hashQueryKeyByOptions } from '../utils'
1617
import { mockOnlineManagerIsOnline, setIsServer } from './utils'
17-
import type {
18-
QueryCache,
19-
QueryFunctionContext,
20-
QueryKey,
21-
QueryObserverResult,
22-
} from '..'
18+
import type { QueryFunctionContext, QueryKey, QueryObserverResult } from '..'
2319

2420
describe('query', () => {
2521
let queryClient: QueryClient
@@ -543,6 +539,35 @@ describe('query', () => {
543539
expect(query.state.error).not.toBeInstanceOf(CancelledError)
544540
})
545541

542+
it('should release the retryer once its fetch has settled', async () => {
543+
const key = queryKey()
544+
let refetch: Promise<unknown> | undefined
545+
const testCache = new QueryCache({
546+
onSuccess: (_data, query) => {
547+
refetch ??= query.fetch()
548+
},
549+
})
550+
const testClient = new QueryClient({ queryCache: testCache })
551+
552+
const prefetch = testClient.prefetchQuery({
553+
queryKey: key,
554+
queryFn: () => sleep(10).then(() => 'data'),
555+
})
556+
const query = testCache.find({ queryKey: key })!
557+
const firstPromise = query.promise
558+
expect(firstPromise).toBeDefined()
559+
560+
await vi.advanceTimersByTimeAsync(10)
561+
await prefetch
562+
expect(query.promise).toBeDefined()
563+
expect(query.promise).not.toBe(firstPromise)
564+
565+
await vi.advanceTimersByTimeAsync(10)
566+
await refetch
567+
expect(query.state.data).toBe('data')
568+
expect(query.promise).toBeUndefined()
569+
})
570+
546571
it('the previous query status should be kept when refetching', async () => {
547572
const key = queryKey()
548573

packages/query-core/src/query.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ export class Query<
533533
}
534534

535535
// Try to fetch the data
536-
this.#retryer = createRetryer({
536+
const retryer = (this.#retryer = createRetryer({
537537
initialPromise: fetchOptions?.initialPromise as
538538
| Promise<TData>
539539
| undefined,
@@ -560,10 +560,10 @@ export class Query<
560560
retryDelay: context.options.retryDelay,
561561
networkMode: context.options.networkMode,
562562
canRun: () => true,
563-
})
563+
}))
564564

565565
try {
566-
const data = await this.#retryer.start()
566+
const data = await retryer.start()
567567
// this is more of a runtime guard
568568
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
569569
if (data === undefined) {
@@ -619,6 +619,11 @@ export class Query<
619619

620620
throw error // rethrow the error for further handling
621621
} finally {
622+
// The settled retryer's promise would otherwise pin this fetch's raw
623+
// result (a second copy after structural sharing) for the query's lifetime
624+
if (this.#retryer === retryer) {
625+
this.#retryer = undefined
626+
}
622627
// Schedule query gc after fetching
623628
this.scheduleGc()
624629
}

0 commit comments

Comments
 (0)