Skip to content

Commit 174e9a9

Browse files
committed
fix(query-core): prevent duplicate abort event listeners in infinite queries
- Add listenerAttached flag to prevent multiple event listener registrations - Add { once: true } option for automatic cleanup - Add test to verify no duplicate listeners when signal is accessed multiple times Fixes memory leak when signal property is accessed multiple times during infinite query pagination.
1 parent f15b7fc commit 174e9a9

File tree

3 files changed

+62
-4
lines changed

3 files changed

+62
-4
lines changed

.changeset/calm-goats-punch.md

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+
Fix memory leak in infinite query by preventing duplicate abort event listeners

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,4 +489,51 @@ describe('InfiniteQueryBehavior', () => {
489489

490490
unsubscribe()
491491
})
492+
493+
test('should not register duplicate abort event listeners when signal is accessed multiple times', async () => {
494+
const key = queryKey()
495+
let signalAccessCount = 0
496+
const listenerCounts: Array<number> = []
497+
498+
const queryFnSpy = vi.fn().mockImplementation(({ signal }) => {
499+
signalAccessCount++
500+
501+
const originalAddEventListener = signal.addEventListener
502+
let currentListenerCount = 0
503+
signal.addEventListener = vi.fn((...args) => {
504+
currentListenerCount++
505+
return originalAddEventListener.apply(signal, args)
506+
})
507+
508+
// Access signal multiple times to trigger getter
509+
signal
510+
signal
511+
signal
512+
513+
listenerCounts.push(currentListenerCount)
514+
signal.addEventListener = originalAddEventListener
515+
516+
return `page-${signalAccessCount}`
517+
})
518+
519+
const observer = new InfiniteQueryObserver(queryClient, {
520+
queryKey: key,
521+
queryFn: queryFnSpy,
522+
getNextPageParam: (_lastPage, pages) => {
523+
return pages.length < 3 ? pages.length + 1 : undefined
524+
},
525+
initialPageParam: 1,
526+
})
527+
528+
const unsubscribe = observer.subscribe(() => {})
529+
530+
await vi.advanceTimersByTimeAsync(0)
531+
532+
await observer.fetchNextPage()
533+
await observer.fetchNextPage()
534+
535+
expect(listenerCounts.every((count) => count <= 1)).toBe(true)
536+
537+
unsubscribe()
538+
})
492539
})

packages/query-core/src/infiniteQueryBehavior.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,22 @@ export function infiniteQueryBehavior<TQueryFnData, TError, TData, TPageParam>(
2222

2323
const fetchFn = async () => {
2424
let cancelled = false
25+
let listenerAttached = false
2526
const addSignalProperty = (object: unknown) => {
2627
Object.defineProperty(object, 'signal', {
2728
enumerable: true,
2829
get: () => {
2930
if (context.signal.aborted) {
3031
cancelled = true
31-
} else {
32-
context.signal.addEventListener('abort', () => {
33-
cancelled = true
34-
})
32+
} else if (!listenerAttached) {
33+
listenerAttached = true
34+
context.signal.addEventListener(
35+
'abort',
36+
() => {
37+
cancelled = true
38+
},
39+
{ once: true },
40+
)
3541
}
3642
return context.signal
3743
},

0 commit comments

Comments
 (0)