Skip to content

Conversation

@kamja44
Copy link

@kamja44 kamja44 commented Dec 9, 2025

🎯 Changes

Fixed a memory leak in infiniteQueryBehavior.ts where abort event listeners were being registered multiple times when the signal property was accessed repeatedly during pagination. Root Cause: The addSignalProperty getter was registering a new abort event listener on every signal access without checking if a listener was already attached. This pattern differed from query.ts, which correctly handles this scenario. Solution:

  • Added listenerAttached flag to prevent duplicate listener registration (line 25, 32-33)
  • Added { once: true } option for automatic listener cleanup (line 39)
  • Added test case to verify no duplicate listeners when signal is accessed multiple times

Files Changed:

  • packages/query-core/src/infiniteQueryBehavior.ts - Fixed duplicate listener bug
  • packages/query-core/src/tests/infiniteQueryBehavior.test.tsx - Added regression test

Impact:

  • Prevents memory leaks in infinite queries with many pages
  • No API changes, fully backward compatible
  • Test coverage: 100% statements, 100% branches for infiniteQueryBehavior.ts

✅ Checklist

  • [O] I have followed the steps in the Contributing guide.
  • [O] I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • [O] This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a memory leak in infinite queries by ensuring abort listeners are not registered more than once, improving stability and memory usage during paginated loads.
  • Tests

    • Added tests confirming abort listeners are attached only once even when the abort signal is accessed repeatedly during query execution.
  • Chores

    • Added a changeset entry to publish the patch.

✏️ Tip: You can customize this high-level summary in your review settings.

…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.
@changeset-bot
Copy link

changeset-bot bot commented Dec 9, 2025

🦋 Changeset detected

Latest commit: 26cc893

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@tanstack/query-core Patch
@tanstack/angular-query-experimental Patch
@tanstack/query-async-storage-persister Patch
@tanstack/query-broadcast-client-experimental Patch
@tanstack/query-persist-client-core Patch
@tanstack/query-sync-storage-persister Patch
@tanstack/react-query Patch
@tanstack/solid-query Patch
@tanstack/svelte-query Patch
@tanstack/vue-query Patch
@tanstack/angular-query-persist-client Patch
@tanstack/react-query-persist-client Patch
@tanstack/solid-query-persist-client Patch
@tanstack/svelte-query-persist-client Patch
@tanstack/react-query-devtools Patch
@tanstack/react-query-next-experimental Patch
@tanstack/solid-query-devtools Patch
@tanstack/svelte-query-devtools Patch
@tanstack/vue-query-devtools Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 9, 2025

Walkthrough

Patches @tanstack/query-core to prevent duplicate abort event listeners in infinite queries by ensuring the listener is attached at most once, and adds tests and a changeset documenting the fix.

Changes

Cohort / File(s) Summary
Changeset
/.changeset/calm-goats-punch.md
Adds a patch changeset entry documenting the memory-leak fix for infinite query abort listener handling and a patch version bump.
Tests
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx
Adds a test verifying that AbortSignal.prototype.addEventListener is not called multiple times when context.signal is accessed repeatedly; test asserts one listener per page across multiple pages. The diff contains a duplicated test block.
Implementation
packages/query-core/src/infiniteQueryBehavior.ts
Adds a listenerAttached flag and guards listener registration so the abort listener on context.signal is attached at most once (uses { once: true }); abort still marks cancellation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Focus areas:
    • Verify correct lifecycle/reset of listenerAttached across pages/fetches and across multiple queryFn invocations.
    • Ensure no edge cases where reattachment is required (e.g., new signals across retries/pages).
    • Remove duplicated test block if unintended and confirm test coverage matches intended behavior.

Suggested reviewers

  • TkDodo

Poem

🐰 I hopped where signals used to grow,
Two ears, then three—oh no, no, no!
One little flag, a single bind,
Now aborts stop leaking, peace of mind. 🥕

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: fixing duplicate abort event listeners in infinite queries, which directly matches the core issue addressed in the changeset.
Description check ✅ Passed The description covers all required template sections: detailed explanation of changes and root cause, completed checklist items, and release impact. All key information is provided with good context.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between edb2bcc and 26cc893.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-22T09:06:05.219Z
Learnt from: sukvvon
Repo: TanStack/query PR: 9892
File: packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx:331-335
Timestamp: 2025-11-22T09:06:05.219Z
Learning: In TanStack/query test files, when a queryFn contains side effects (e.g., setting flags for test verification), prefer async/await syntax for clarity; when there are no side effects, prefer the .then() pattern for conciseness.

Applied to files:

  • packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx
🔇 Additional comments (1)
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1)

493-544: LGTM! Test correctly validates the memory leak fix.

The test properly addresses the previous review concern by installing the spy on AbortSignal.prototype.addEventListener before the observer is created, ensuring all listener registrations are captured. The assertion correctly validates that despite accessing context.signal three times per page across three pages (9 total accesses), only 3 abort listeners are registered—one per page as intended by the fix.

The test is well-structured with clear comments explaining both the expected behavior with the fix and what would happen without it.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f15b7fc and 174e9a9.

📒 Files selected for processing (3)
  • .changeset/calm-goats-punch.md (1 hunks)
  • packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1 hunks)
  • packages/query-core/src/infiniteQueryBehavior.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TkDodo
Repo: TanStack/query PR: 9612
File: packages/query-async-storage-persister/src/asyncThrottle.ts:0-0
Timestamp: 2025-09-02T17:57:33.184Z
Learning: When importing from tanstack/query-core in other TanStack Query packages like query-async-storage-persister, a workspace dependency "tanstack/query-core": "workspace:*" needs to be added to the package.json.
📚 Learning: 2025-11-22T09:06:05.219Z
Learnt from: sukvvon
Repo: TanStack/query PR: 9892
File: packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx:331-335
Timestamp: 2025-11-22T09:06:05.219Z
Learning: In TanStack/query test files, when a queryFn contains side effects (e.g., setting flags for test verification), prefer async/await syntax for clarity; when there are no side effects, prefer the .then() pattern for conciseness.

Applied to files:

  • packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx
🧬 Code graph analysis (1)
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (2)
packages/query-core/src/queriesObserver.ts (1)
  • observer (270-276)
packages/query-core/src/infiniteQueryObserver.ts (1)
  • InfiniteQueryObserver (26-185)
🔇 Additional comments (2)
.changeset/calm-goats-punch.md (1)

1-5: Changeset description matches the behavioral fix

Patch bump and summary line correctly capture the memory-leak fix scope for @tanstack/query-core. No issues from a release-notes perspective.

packages/query-core/src/infiniteQueryBehavior.ts (1)

23-45: Abort-listener guard looks correct and fixes the leak

Scoping listenerAttached inside fetchFn and guarding addEventListener('abort', ...) with it ensures at most one abort listener per fetch, even when multiple pages are fetched or multiple contexts are created. Using { once: true } also keeps the listener lifecycle tight after an abort. This aligns with the intended cancellation semantics while removing the memory-leak vector.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1)

492-548: New abort-listener regression test is effective; minor robustness/clarity nits

The overall shape of this test looks good: spying on AbortSignal.prototype.addEventListener before creating the observer will correctly see the real abort listener registrations, and asserting against the total count will fail if we regress back to “one listener per signal access”.

A couple of small, non-blocking tweaks you might consider:

  • The previousCallCount / listenersForThisPage logic is computed before the context.signal accesses and never used in any assertion, so it doesn’t actually reflect “listeners added for this page” and just adds noise. You could simplify the test by dropping that bookkeeping and returning a constant page value instead.
  • expect(totalAbortListeners).toBe(3) bakes in the current implementation detail “exactly one abort listener per page”. For the leak this PR is targeting, what really matters is that we don’t exceed one registration per page despite accessing signal three times. To keep the test resilient to future internal refactors (e.g. sharing a single signal across pages while still avoiding duplicates), you could loosen this to toBeLessThanOrEqual(3) while still catching the regression from 3 → 9 calls.

For example:

-  let previousCallCount = 0
-
-  const queryFnSpy = vi.fn().mockImplementation((context) => {
-    const currentCallCount = addEventListenerSpy.mock.calls.filter(
-      (call) => call[0] === 'abort'
-    ).length
-
-    // Calculate how many listeners were added for this page
-    const listenersForThisPage = currentCallCount - previousCallCount
-    previousCallCount = currentCallCount
-
-    // 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-${listenersForThisPage}`
-  })
+  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'
+  })
@@
-  expect(totalAbortListeners).toBe(3)
+  expect(totalAbortListeners).toBeLessThanOrEqual(3)

These are just to reduce cognitive load and make the test a bit more future-proof; the existing test already exercises the leak scenario meaningfully.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 174e9a9 and f22f67b.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TkDodo
Repo: TanStack/query PR: 9612
File: packages/query-async-storage-persister/src/asyncThrottle.ts:0-0
Timestamp: 2025-09-02T17:57:33.184Z
Learning: When importing from tanstack/query-core in other TanStack Query packages like query-async-storage-persister, a workspace dependency "tanstack/query-core": "workspace:*" needs to be added to the package.json.
📚 Learning: 2025-11-22T09:06:05.219Z
Learnt from: sukvvon
Repo: TanStack/query PR: 9892
File: packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx:331-335
Timestamp: 2025-11-22T09:06:05.219Z
Learning: In TanStack/query test files, when a queryFn contains side effects (e.g., setting flags for test verification), prefer async/await syntax for clarity; when there are no side effects, prefer the .then() pattern for conciseness.

Applied to files:

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

@kamja44 kamja44 force-pushed the fix/infinite-query-memory-leak branch from f22f67b to fd66a1f Compare December 9, 2025 03:26
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1)

493-539: Abort-listener regression test correctly targets the leak; consider slightly tightening it

This now spies at the right level (AbortSignal.prototype.addEventListener) and the “3 pages × 3 signal reads vs ≤3 abort listeners” check will actually catch the previous leak, which is great.

If you want to harden it a bit more (optional):

  • Use expect(totalAbortListeners).toBe(3) instead of <= 3 so a future change that stops attaching any listener at all doesn’t silently pass (cancellation behavior is covered elsewhere, but this would make the intent self-contained here).
  • Wrap the body in a try/finally to always run addEventListenerSpy.mockRestore() and unsubscribe() even if an earlier assertion fails, avoiding potential cross-test contamination.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f22f67b and fd66a1f.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TkDodo
Repo: TanStack/query PR: 9612
File: packages/query-async-storage-persister/src/asyncThrottle.ts:0-0
Timestamp: 2025-09-02T17:57:33.184Z
Learning: When importing from tanstack/query-core in other TanStack Query packages like query-async-storage-persister, a workspace dependency "tanstack/query-core": "workspace:*" needs to be added to the package.json.
📚 Learning: 2025-11-22T09:06:05.219Z
Learnt from: sukvvon
Repo: TanStack/query PR: 9892
File: packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx:331-335
Timestamp: 2025-11-22T09:06:05.219Z
Learning: In TanStack/query test files, when a queryFn contains side effects (e.g., setting flags for test verification), prefer async/await syntax for clarity; when there are no side effects, prefer the .then() pattern for conciseness.

Applied to files:

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

@kamja44 kamja44 force-pushed the fix/infinite-query-memory-leak branch from fd66a1f to edb2bcc Compare December 9, 2025 04:25
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1)

493-541: Regression test correctly exercises and guards the abort-listener leak fix

Spying on AbortSignal.prototype.addEventListener and counting only 'abort' registrations gives a robust signal that the getter no longer attaches multiple listeners per access; driving three pages and asserting totalAbortListeners === 3 will reliably fail if the old behavior (3× per access) is reintroduced. The try/finally with mockRestore() and unsubscribe() also keeps the test well-isolated.

If you want an extra bit of clarity (optional), you could assert the number of page fetches alongside the listener count, e.g. after the two fetchNextPage() calls:

       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
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fd66a1f and edb2bcc.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: TkDodo
Repo: TanStack/query PR: 9612
File: packages/query-async-storage-persister/src/asyncThrottle.ts:0-0
Timestamp: 2025-09-02T17:57:33.184Z
Learning: When importing from tanstack/query-core in other TanStack Query packages like query-async-storage-persister, a workspace dependency "tanstack/query-core": "workspace:*" needs to be added to the package.json.
📚 Learning: 2025-11-22T09:06:05.219Z
Learnt from: sukvvon
Repo: TanStack/query PR: 9892
File: packages/solid-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx:331-335
Timestamp: 2025-11-22T09:06:05.219Z
Learning: In TanStack/query test files, when a queryFn contains side effects (e.g., setting flags for test verification), prefer async/await syntax for clarity; when there are no side effects, prefer the .then() pattern for conciseness.

Applied to files:

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

…teners

Based on code review feedback, the previous test approach had a flaw:
- Signal destructuring ({signal}) invokes the getter before addEventListener could be spied
- The test was not actually catching duplicate listener registrations

New approach:
- Spy on AbortSignal.prototype.addEventListener before query execution
- Access signal multiple times within queryFn to trigger getter repeatedly
- Verify that only 3 abort listeners are registered (1 per page) instead of 9 (3 per page)

This test now properly validates the memory leak fix and will fail if the
duplicate listener prevention is removed.
@kamja44 kamja44 force-pushed the fix/infinite-query-memory-leak branch from edb2bcc to 26cc893 Compare December 9, 2025 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant