diff --git a/.changeset/calm-goats-punch.md b/.changeset/calm-goats-punch.md new file mode 100644 index 0000000000..04e2bc3df8 --- /dev/null +++ b/.changeset/calm-goats-punch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Fix memory leak in infinite query by preventing duplicate abort event listeners diff --git a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx index db96ea17da..62e4cf34aa 100644 --- a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx @@ -489,4 +489,57 @@ describe('InfiniteQueryBehavior', () => { unsubscribe() }) + + test('should not register duplicate abort event listeners when signal is accessed multiple times', async () => { + const key = queryKey() + + // Track addEventListener calls before the query starts + const addEventListenerSpy = vi.spyOn(AbortSignal.prototype, 'addEventListener') + + const queryFnSpy = vi.fn().mockImplementation((context) => { + // Access signal multiple times to trigger the getter repeatedly + // This simulates code that might reference the signal property multiple times + context.signal + context.signal + context.signal + + return 'page' + }) + + const observer = new InfiniteQueryObserver(queryClient, { + queryKey: key, + queryFn: queryFnSpy, + getNextPageParam: (_lastPage, pages) => { + return pages.length < 3 ? pages.length + 1 : undefined + }, + initialPageParam: 1, + }) + + const unsubscribe = observer.subscribe(() => {}) + + try { + // Wait for initial page + await vi.advanceTimersByTimeAsync(0) + + // Fetch additional pages + await observer.fetchNextPage() + await observer.fetchNextPage() + + // Sanity check: we fetched 3 pages (initial + 2 next pages) + expect(queryFnSpy).toHaveBeenCalledTimes(3) + + // Count total abort listeners registered + const totalAbortListeners = addEventListenerSpy.mock.calls.filter( + (call) => call[0] === 'abort' + ).length + + // With the fix: Each page registers exactly 1 abort listener despite signal being accessed 3 times + // We fetch 3 pages, so exactly 3 abort listeners + // Without the fix: Each signal access registers a listener = 3 accesses × 3 pages = 9 listeners + expect(totalAbortListeners).toBe(3) + } finally { + addEventListenerSpy.mockRestore() + unsubscribe() + } + }) }) diff --git a/packages/query-core/src/infiniteQueryBehavior.ts b/packages/query-core/src/infiniteQueryBehavior.ts index 476d90ce15..9e3237c8c2 100644 --- a/packages/query-core/src/infiniteQueryBehavior.ts +++ b/packages/query-core/src/infiniteQueryBehavior.ts @@ -22,16 +22,22 @@ export function infiniteQueryBehavior( const fetchFn = async () => { let cancelled = false + let listenerAttached = false const addSignalProperty = (object: unknown) => { Object.defineProperty(object, 'signal', { enumerable: true, get: () => { if (context.signal.aborted) { cancelled = true - } else { - context.signal.addEventListener('abort', () => { - cancelled = true - }) + } else if (!listenerAttached) { + listenerAttached = true + context.signal.addEventListener( + 'abort', + () => { + cancelled = true + }, + { once: true }, + ) } return context.signal },