react-native-pager-view icon indicating copy to clipboard operation
react-native-pager-view copied to clipboard

Fix memory leaks when PagerView is unmounted

Open Copilot opened this issue 2 months ago • 6 comments

Summary

Fixes #702 - Memory leaks when component is disposed

When PagerView is unmounted, delegates and callbacks retain references to the component, preventing deallocation. This causes memory to accumulate proportionally to the resources held by active pages.

iOS (RNCPagerViewComponentView.mm)

  • Clear UIPageViewController and UIScrollView delegates in dealloc, willMoveToSuperview, and prepareForRecycle
  • Release view controllers array in prepareForRecycle

Android

  • Store OnPageChangeCallback reference in NestedScrollableHost for cleanup
  • Unregister callback and clear adapter in onDropViewInstance (PagerViewViewManager)
  • Remove views from containers in onViewRecycled (ViewPagerAdapter)

Project Configuration

  • Removed accidentally added package-lock.json file (project uses Bun, not npm)
  • Added package-lock.json to .gitignore to prevent future additions

Test Plan

What's required for testing (prerequisites)?

  • React Native app with PagerView
  • Memory profiler (Xcode Instruments for iOS, Android Studio Profiler for Android)

What are the steps to reproduce (after prerequisites)?

  1. Mount PagerView with large resources (e.g., high-resolution images)
  2. Unmount the component (navigate away or toggle visibility)
  3. Repeat steps 1-2 multiple times
  4. Observe memory usage remains stable instead of accumulating

Compatibility

OS Implemented
iOS
Android

Checklist

  • [ ] I have tested this on a device and a simulator
  • [ ] I added the documentation in README.md
  • [ ] I updated the typed files (TS and Flow)

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.


[!NOTE] Cleans up PagerView callbacks, delegates, and adapters on unmount (iOS/Android) and recycles holder views; adds package-lock.json to .gitignore.

  • Android:
    • Store OnPageChangeCallback in NestedScrollableHost and unregister it in onDropViewInstance.
    • Clear ViewPager2 adapter in onDropViewInstance to release child references.
    • Remove child views in ViewPagerAdapter.onViewRecycled.
  • iOS:
    • Clear UIPageViewController and UIScrollView delegates in dealloc, willMoveToSuperview, and prepareForRecycle.
    • Reset scrollView and empty _nativeChildrenViewControllers during recycle.
  • Config:
    • Add package-lock.json to .gitignore.

Written by Cursor Bugbot for commit 71650e3c79e8dda31eb86c849925621fc0c5854b. This will update automatically on new commits. Configure here.

Copilot avatar Nov 16 '25 09:11 Copilot

this addresses only the Fabric, it should also address the Paper.

efstathiosntonas avatar Nov 16 '25 10:11 efstathiosntonas

Hello @efstathiosntonas From version 7.x, only the new architecture is supported.

https://github.com/callstack/react-native-pager-view?tab=readme-ov-file#migration

troZee avatar Nov 16 '25 10:11 troZee

@troZee understood but this patch would suffice:

  diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  index eacfbe8..c61745f 100644
  --- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  +++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  @@ -44,7 +44,7 @@ - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher {
           _dismissKeyboard = UIScrollViewKeyboardDismissModeNone;
           _coalescingKey = 0;
           _eventDispatcher = eventDispatcher;
  -        _cachedControllers = [NSHashTable hashTableWithOptions:NSHashTableStrongMemory];
  +        _cachedControllers = [NSHashTable hashTableWithOptions:NSHashTableWeakMemory];
           _overdrag = NO;
           _layoutDirection = @"ltr";
       }

It would be nice if it could be squeezed 😅

efstathiosntonas avatar Nov 16 '25 10:11 efstathiosntonas

@copilot Could you also apply and test the following patch

  diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  index eacfbe8..c61745f 100644
  --- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  +++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  @@ -44,7 +44,7 @@ - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher {
           _dismissKeyboard = UIScrollViewKeyboardDismissModeNone;
           _coalescingKey = 0;
           _eventDispatcher = eventDispatcher;
  -        _cachedControllers = [NSHashTable hashTableWithOptions:NSHashTableStrongMemory];
  +        _cachedControllers = [NSHashTable hashTableWithOptions:NSHashTableWeakMemory];
           _overdrag = NO;
           _layoutDirection = @"ltr";
       }

troZee avatar Nov 16 '25 10:11 troZee

@copilot Could you also apply and test the following patch

...

The file ReactNativePageView.m doesn't exist in the current codebase. Version 7.x only supports the new architecture (Fabric), so there's no Paper architecture implementation to patch.

The equivalent memory leak fix for the new architecture has already been applied in commit 4bb3c51 to RNCPagerViewComponentView.mm, where delegates are properly cleared in dealloc, willMoveToSuperview, and prepareForRecycle.

If you need support for the old Paper architecture (versions < 7.x), this would need to be applied to a different branch that still contains ReactNativePageView.m.

Copilot avatar Nov 16 '25 10:11 Copilot

thanks @troZee !

I just runned it on ios and on a Pixel7a A16 and no issues so far. I use this as a photo carousel on posts so usage is extremely heavy per render on the list. So far so good.

using Paper.

leaving the full patch here just in case another poor soul is still using Paper:

react-native-pager-view+6.8.1.patch

Click me to reveal patch
diff --git a/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt b/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt
  index 87b58d0f..e9d0ace1 100644
  --- a/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt
  +++ b/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt
  @@ -25,6 +25,7 @@ class NestedScrollableHost : FrameLayout {
     constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
     public var initialIndex: Int? = null
     public var didSetInitialIndex = false
  +  public var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
     private var touchSlop = 0
     private var initialX = 0f
     private var initialY = 0f
  diff --git a/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt b/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt
  index 8ec286a7..19f46363 100644
  --- a/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt
  +++ b/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt
  @@ -52,7 +52,7 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
           vp.isSaveEnabled = false

           vp.post {
  -            vp.registerOnPageChangeCallback(object : OnPageChangeCallback() {
  +            val callback = object : OnPageChangeCallback() {
                   override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
                       super.onPageScrolled(position, positionOffset, positionOffsetPixels)
                       UIManagerHelper.getEventDispatcherForReactTag(reactContext, host.id)?.dispatchEvent(
  @@ -79,7 +79,9 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
                               PageScrollStateChangedEvent(host.id, pageScrollState)
                       )
                   }
  -            })
  +            }
  +            host.pageChangeCallback = callback
  +            vp.registerOnPageChangeCallback(callback)
               UIManagerHelper.getEventDispatcherForReactTag(reactContext, host.id)?.dispatchEvent(
                       PageSelectedEvent(host.id, vp.currentItem)
               )
  @@ -200,6 +202,20 @@ class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>(), RNCViewPa
           }
       }

  +    override fun onDropViewInstance(view: NestedScrollableHost) {
  +        // Unregister the page change callback to prevent memory leaks
  +        val viewPager = PagerViewViewManagerImpl.getViewPager(view)
  +        view.pageChangeCallback?.let { callback ->
  +            viewPager.unregisterOnPageChangeCallback(callback)
  +            view.pageChangeCallback = null
  +        }
  +
  +        // Clear the adapter to release references to child views
  +        viewPager.adapter = null
  +
  +        super.onDropViewInstance(view)
  +    }
  +
       override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Map<String, String>> {
           return MapBuilder.of(
                   PageScrollEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPageScroll"),
  diff --git a/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/ViewPagerAdapter.kt b/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/ViewPagerAdapter.kt
  index fd3530e1..8aab5f66 100644
  --- a/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/ViewPagerAdapter.kt
  +++ b/node_modules/react-native-pager-view/android/src/main/java/com/reactnativepagerview/ViewPagerAdapter.kt
  @@ -30,6 +30,12 @@ class ViewPagerAdapter() : Adapter<ViewPagerViewHolder>() {
       container.addView(child)
     }

  +  override fun onViewRecycled(holder: ViewPagerViewHolder) {
  +    super.onViewRecycled(holder)
  +    // Clean up the holder's container to prevent memory leaks
  +    holder.container.removeAllViews()
  +  }
  +
     override fun getItemCount(): Int {
       return childrenViews.size
     }
  diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  index eacfbe8..c61745f 100644
  --- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  +++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
  @@ -44,7 +44,7 @@ - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher {
           _dismissKeyboard = UIScrollViewKeyboardDismissModeNone;
           _coalescingKey = 0;
           _eventDispatcher = eventDispatcher;
  -        _cachedControllers = [NSHashTable hashTableWithOptions:NSHashTableStrongMemory];
  +        _cachedControllers = [NSHashTable hashTableWithOptions:NSHashTableWeakMemory];
           _overdrag = NO;
           _layoutDirection = @"ltr";
       }

efstathiosntonas avatar Nov 16 '25 10:11 efstathiosntonas