Skip to content

fix(ScrollArea): cancel ScrollAreaThumb RAF loop and clean up Presence listeners on unmount#2744

Merged
zernonia merged 2 commits into
unovue:v2from
yan-ad:fix/memory-leak-portal-scrollarea
Jun 24, 2026
Merged

fix(ScrollArea): cancel ScrollAreaThumb RAF loop and clean up Presence listeners on unmount#2744
zernonia merged 2 commits into
unovue:v2from
yan-ad:fix/memory-leak-portal-scrollarea

Conversation

@yan-ad

@yan-ad yan-ad commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #2420 — Memory leak from detached DOM nodes in Popover/Tooltip/ScrollArea after route navigation.

Root Causes

  1. ScrollAreaThumb RAF loop never cancelled — addUnlinkedScrollListener starts a perpetual requestAnimationFrame loop that polls scroll position. The returned cancellation function was stored in a ref but onUnmounted only removed the scroll event listener — it never called the cancellation function. The RAF loop continued running forever, retaining DOM references to the viewport element.

  2. Presence listeners not cleaned up on unmount — usePresence attaches animationstart/animationcancel/animationend listeners to the DOM node. These were removed only when the node ref changed, but in onUnmounted they were never explicitly cleaned up. With teleported content, these leaked listeners can prevent garbage collection.

Fixes

  • ScrollAreaThumb: call removeUnlinkedScrollListenerRef.value?.() in onUnmounted to cancel the RAF loop. Also removed the duplicate viewport event listener removal.
  • Presence: explicitly remove animation event listeners in onUnmounted before stopping the node watcher.

Testing

  • All 9 Presence tests pass
  • All 9 ScrollArea tests pass

Summary by CodeRabbit

  • Bug Fixes
    • Improved unmount cleanup for presence handling by removing animation-related event listeners and canceling any pending timeouts.
    • Refined ScrollArea thumb unmount behavior to properly detach the unlinked scroll listener and avoid redundant native scroll listener removal.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c24a1199-5ed2-41e4-88fc-eee7a9c2752a

📥 Commits

Reviewing files that changed from the base of the PR and between 97784e4 and 311432a.

📒 Files selected for processing (2)
  • packages/core/src/Presence/usePresence.ts
  • packages/core/src/ScrollArea/ScrollAreaThumb.vue
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/core/src/Presence/usePresence.ts
  • packages/core/src/ScrollArea/ScrollAreaThumb.vue

📝 Walkthrough

Walkthrough

Two onUnmounted cleanup paths are tightened to fix memory leaks. usePresence now explicitly removes animation event listeners (animationstart, animationcancel, animationend) from the node and clears any pending timeoutId. ScrollAreaThumb replaces a direct removeEventListener call with an invocation of the stored removeUnlinkedScrollListenerRef cleanup function.

Changes

Memory Leak Fixes: onUnmounted Cleanup

Layer / File(s) Summary
Animation and scroll listener teardown on unmount
packages/core/src/Presence/usePresence.ts, packages/core/src/ScrollArea/ScrollAreaThumb.vue
usePresence adds explicit removal of animationstart, animationcancel, and animationend handlers plus ownerWindow?.clearTimeout(timeoutId) in onUnmounted. ScrollAreaThumb replaces viewport.removeEventListener('scroll', handleScroll) with removeUnlinkedScrollListenerRef.value?.() to properly invoke the stored teardown callback.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~3 minutes

Poem

🐇 Hop, hop — no ghost nodes tonight,
The listeners vanish, the timeouts take flight.
ScrollArea thumbs wave a tidy goodbye,
Animations clean up as the components die.
No memory leaks left — the heap breathes a sigh! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the two main fixes: canceling ScrollAreaThumb RAF loop and cleaning up Presence listeners on unmount, which directly match the code changes.
Linked Issues check ✅ Passed The PR directly addresses the memory leak requirements from issue #2420 by implementing the two specific fixes: removing RAF loop continuation in ScrollAreaThumb and explicitly cleaning up animation listeners in Presence onUnmount.
Out of Scope Changes check ✅ Passed All changes are focused and directly address the memory leak issues: ScrollAreaThumb RAF cancellation and Presence listener cleanup, with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


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.

@pkg-pr-new

pkg-pr-new Bot commented Jun 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/reka-ui@2744

commit: 311432a

…e listeners on unmount

- ScrollAreaThumb: call removeUnlinkedScrollListenerRef to cancel
  the requestAnimationFrame loop on unmount, preventing a perpetual
  RAF loop that retains DOM references after component destruction
- ScrollAreaThumb: remove duplicate viewport event listener removal
- Presence: explicitly remove animation event listeners from the
  DOM node when the host component unmounts, preventing leaked
  listeners on teleported nodes

Closes unovue#2420
@yan-ad yan-ad changed the title fix: cancel ScrollAreaThumb RAF loop and clean up Presence listeners on unmount fix(ScrollArea): cancel ScrollAreaThumb RAF loop and clean up Presence listeners on unmount Jun 23, 2026
@yan-ad yan-ad force-pushed the fix/memory-leak-portal-scrollarea branch from 97784e4 to 4cc6a96 Compare June 23, 2026 08:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/core/src/ScrollArea/ScrollAreaThumb.vue (1)

66-66: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Pre-existing: Consider defensive check for viewport existence.

The non-null assertion (!) assumes viewport.value is defined at unmount. While unlikely given rootContext.viewport is injected from the parent, a defensive check would be safer:

-  viewport.value!.removeEventListener('scroll', handleScroll)
+  viewport.value?.removeEventListener('scroll', handleScroll)

This is a pre-existing pattern, not introduced by this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/ScrollArea/ScrollAreaThumb.vue` at line 66, In the unmount
lifecycle hook where `viewport.value!.removeEventListener('scroll',
handleScroll)` is called, replace the non-null assertion with a defensive check
to verify viewport.value exists before attempting to remove the event listener.
Use a conditional statement or optional chaining to safely access viewport.value
and only proceed with the removeEventListener call if the value is defined.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/ScrollArea/ScrollAreaThumb.vue`:
- Line 66: In the unmount lifecycle hook where
`viewport.value!.removeEventListener('scroll', handleScroll)` is called, replace
the non-null assertion with a defensive check to verify viewport.value exists
before attempting to remove the event listener. Use a conditional statement or
optional chaining to safely access viewport.value and only proceed with the
removeEventListener call if the value is defined.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f85c8d05-070c-418e-ae28-d7de8d00a58e

📥 Commits

Reviewing files that changed from the base of the PR and between 178e6cd and 97784e4.

📒 Files selected for processing (2)
  • packages/core/src/Presence/usePresence.ts
  • packages/core/src/ScrollArea/ScrollAreaThumb.vue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Memory leak - Detached DOM nodes from PopoverPortal, TooltipPortal, and ScrollArea after route navigation

2 participants