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/calm-goats-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': patch
---

Fix memory leak in infinite query by preventing duplicate abort event listeners
53 changes: 53 additions & 0 deletions packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
})
14 changes: 10 additions & 4 deletions packages/query-core/src/infiniteQueryBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ export function infiniteQueryBehavior<TQueryFnData, TError, TData, TPageParam>(

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
},
Expand Down