Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/removed-query-gc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/query-core": patch
---

Avoid restarting garbage-collection timers for removed queries when their last observer unsubscribes or a cancelled fetch settles.
72 changes: 72 additions & 0 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
dehydrate,
hydrate,
noop,
skipToken,
timeoutManager,
} from '..'
import { hashQueryKeyByOptions } from '../utils'
import { mockOnlineManagerIsOnline, setIsServer } from './utils'
Expand All @@ -34,6 +36,76 @@ describe('query', () => {
vi.useRealTimers()
})

describe('garbage collection after removal', () => {
it.each([false, true])(
'does not reschedule GC on unsubscribe (replacement: %s)',
(replace) => {
const key = queryKey()
const gcTime = 30_000
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: skipToken,
initialData: 'data',
gcTime,
staleTime: Infinity,
})
const unsubscribe = observer.subscribe(noop)
const oldQuery = observer.getCurrentQuery()
queryClient.removeQueries({ queryKey: key })
if (replace) {
queryClient.setQueryData(key, 'replacement')
}
const schedule = vi.spyOn(timeoutManager, 'setTimeout')
schedule.mockClear()

unsubscribe()

expect(oldQuery.getObserversCount()).toBe(0)
expect(queryCache.get(oldQuery.queryHash)).not.toBe(oldQuery)
expect(schedule).not.toHaveBeenCalledWith(expect.any(Function), gcTime)
expect(queryClient.getQueryData(key)).toBe(
replace ? 'replacement' : undefined,
)
schedule.mockRestore()
},
)

it.each([false, true])(
'does not reschedule GC when cancellation settles (replacement: %s)',
async (replace) => {
const key = queryKey()
const gcTime = 30_000
let resolve!: (data: string) => void
const request = new Promise<string>((done) => {
resolve = done
})
const fetch = queryClient
.query({
queryKey: key,
queryFn: () => request,
gcTime,
})
.catch(noop)
queryClient.removeQueries({ queryKey: key })
if (replace) {
queryClient.setQueryData(key, 'replacement')
}
const schedule = vi.spyOn(timeoutManager, 'setTimeout')
schedule.mockClear()

await fetch
resolve('late data')
await Promise.resolve()

expect(schedule).not.toHaveBeenCalledWith(expect.any(Function), gcTime)
expect(queryClient.getQueryData(key)).toBe(
replace ? 'replacement' : undefined,
)
schedule.mockRestore()
},
)
})

it('should use the longest garbage collection time it has seen', async () => {
const key = queryKey()
await queryClient
Expand Down
20 changes: 16 additions & 4 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@ export class Query<
* @internal
* @param observer - The observer to remove. When the last observer is removed, the in-flight
* fetch is cancelled if its abort signal was consumed (otherwise only its retries are stopped),
* and garbage collection is scheduled.
* and garbage collection is scheduled if this query is still cached.
*/
removeObserver(observer: QueryObserver<any, any, any, any, any>): void {
const index = this.observers.indexOf(observer)
Expand All @@ -647,7 +647,13 @@ export class Query<
}
}

this.scheduleGc()
if (
this.#cache.get<TQueryFnData, TError, TData, TQueryKey>(
this.queryHash,
) === this
) {
this.scheduleGc()
}
}

this.#cache.notify({ type: 'observerRemoved', query: this, observer })
Expand Down Expand Up @@ -926,8 +932,14 @@ export class Query<
if (this.#retryer === retryer) {
this.#retryer = undefined
}
// Schedule query gc after fetching
this.scheduleGc()
// A removed query may still be finishing cancellation.
if (
this.#cache.get<TQueryFnData, TError, TData, TQueryKey>(
this.queryHash,
) === this
) {
this.scheduleGc()
}
}
}

Expand Down