diff --git a/.changeset/removed-query-gc.md b/.changeset/removed-query-gc.md new file mode 100644 index 00000000000..30efde8d92e --- /dev/null +++ b/.changeset/removed-query-gc.md @@ -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. diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 9d924d9315d..e3bc075ac8e 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -13,6 +13,8 @@ import { dehydrate, hydrate, noop, + skipToken, + timeoutManager, } from '..' import { hashQueryKeyByOptions } from '../utils' import { mockOnlineManagerIsOnline, setIsServer } from './utils' @@ -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((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 diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index bd895805ab0..a94d192f6ca 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -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): void { const index = this.observers.indexOf(observer) @@ -647,7 +647,13 @@ export class Query< } } - this.scheduleGc() + if ( + this.#cache.get( + this.queryHash, + ) === this + ) { + this.scheduleGc() + } } this.#cache.notify({ type: 'observerRemoved', query: this, observer }) @@ -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( + this.queryHash, + ) === this + ) { + this.scheduleGc() + } } }