[Android] Never-attached ScrollView with maintainVisibleContentPosition leaks Activities

Ouverte
#59,935 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Les mainteneurs répondent en général sous 2 jours

Personne n'a encore pris cette issue.

Évaluation

Difficulté
4/5
Temps estimé
3-5 jours
Accessibilité débutants
45/100
Type d'issue
Bug
Clarté
Clairement spécifiée
Activité
Active
Stack technique
android, kotlin, react-native
Domaine
backend, mobile

Piste de recherche

Read the lifecycle handling in ReactScrollView.kt, ReactHorizontalScrollView.kt, and ReactNestedScrollView.kt, then trace helper registration in MaintainVisibleScrollPositionHelper.kt and view cleanup in the three scroll view managers. Compare the two proposed fixes linked in the issue and check existing Android scroll view manager tests. Done means a never-attached view no longer leaves its helper registered after deletion, for all three scroll view types.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

Component: ScrollView Needs: Triage :mag: Platform: Android Type: New Architecture
Description

On Android, a ScrollView with maintainVisibleContentPosition that is mounted but never attached to a window keeps destroyed Activities in memory. In the reproducer, 10 Activity recreations (display density changes) leave 11 MainActivity instances, and all 11 are still there after 60 s idle and am send-trim-memory RUNNING_CRITICAL. Without the prop, the count drops back to 1. Without any recreation, each remount of such a ScrollView leaves one more MaintainVisibleScrollPositionHelper registered with FabricUIManager: 11 after 10 remounts.

The prop setter registers the helper as a FabricUIManager listener right away, and only onDetachedFromWindow (or setting the prop back to null) unregisters it. A view that is never attached is never detached, so the helper, its ScrollView and that view's Activity stay reachable for the life of the React instance. A ScrollView that was attached and later detached is not affected.

Where it happens: any ScrollView-based list with this prop that is mounted but never attached to a window. I found it with a list in an eagerly rendered tab that had not been shown yet. In that app, each display-size change kept the whole previous screen (about 1,200 views), and an API 29 emulator with a 48 MB heap growth limit died with OutOfMemoryError at the 11th recreation. Those two numbers come from that app, not from the reproducer. Two cases that should behave the same, not measured: a list with the prop inside a FlatList cell that is off screen from the start (removeClippedSubviews is the FlatList default on Android), and recreations caused by a font-size or language change (the template's MainActivity also leaves fontScale and locale out of configChanges).

ReactHorizontalScrollView and ReactNestedScrollView have the same code. It is unchanged on main (b16032065) and 0.89-stable (195bd5955).

In the cli info below, "Android SDK: Not Found" and "Yarn: Not Found" are wrong: the SDK (android-37.0, build-tools 37.0.0, NDK 27.1.12297006) is installed, and Yarn 1.22.22 ran through Corepack.

Steps to reproduce

Reproducer: https://github.com/nazar-pal/rn-mvcp-activity-leak-repro

The app puts a ScrollView with maintainVisibleContentPosition={{minIndexForVisible: 0}} below the bounds of a 40 dp View with removeClippedSubviews. ReactViewGroup keeps it out of the view hierarchy, so it is created and gets its props but is never attached. A button switches to the same ScrollView without the prop (control), and another remounts it.

From the repository root:

  1. Build and install the Release build:
    (cd ReproducerApp && yarn install)
    (cd ReproducerApp/android && ./gradlew :app:assembleRelease)
    adb install -r ReproducerApp/android/app/build/outputs/apk/release/app-release.apk
    
  2. Start it: adb shell am start -W -S -n com.reproducerapp/.MainActivity
  3. Run ReproducerApp/scripts/recreate.sh 10. It waits for the app's UI, then recreates MainActivity 10 times by switching the display density between the current value and one 15% higher. It prints Activities from dumpsys meminfo after each recreation, after 60 s idle and after the trim. By hand: adb shell wm density 552 (any value other than the current one; the API 37 emulator's is 480), adb shell wm density reset, then adb shell dumpsys meminfo com.reproducerapp | grep Activities.
  4. For the control, restart the app (step 2), tap Switch to control (no prop), and repeat step 3. Restart first: the Activities kept by the run with the prop stay in memory until the process ends.
  5. For result 2: build a debuggable Release with (cd ReproducerApp/android && ./gradlew -I ../scripts/debuggable-release.gradle :app:assembleRelease) (a user emulator image refuses am dumpheap for a non-debuggable app), install it, tap Remount hidden ScrollView 10 times, take am dumpheap -g, and run the repo's tools/heap-check on it. The README has the exact commands.

Expected: destroyed Activities are collected, as in the control. Deleted ScrollViews do not stay registered.

Actual: measured on two emulators: Android 17 / API 37 Google Play image (user build, arm64) and Android 10 / API 29 Google APIs image (userdebug build, arm64). No real device was tested.

Activities from dumpsys meminfo, official Release build, a reading 10 s after each recreation:

Device and build cold after recreations 1 to 10 60 s idle after trim
API 37 emulator, 0.88.1 Release, with the prop 1 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 11 11
API 29 emulator, 0.88.1 Release, with the prop 1 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 11 11
API 37 emulator, 0.89.0-rc.1 Release, with the prop 1 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 11 11
API 37 emulator, 0.88.1 Release, control (no prop) 1 2 each time 1 1
API 29 emulator, 0.88.1 Release, control (no prop) 1 2 each time 1 1

With the prop, Views grows by one whole view tree per recreation: 19 → 209 on API 37 and 20 → 220 on API 29. The control's 2 is the previous Activity before the next GC.

Heap dumps after a forced GC (am dumpheap -g):

Case Device and build Destroyed MainActivity Helpers in FabricUIManager.mListeners
With prop, 4 recreations API 37 emulator, debuggable 0.88.1 Release 4 5 (4 from destroyed surfaces, 1 live)
With prop, 4 recreations API 29 emulator, official 0.88.1 Release 4 5 (4 from destroyed surfaces, 1 live)
With prop, 10 remounts, no recreation API 37 emulator, debuggable 0.88.1 Release 0 11 (10 deleted views, 1 live)
Control, 4 recreations and 10 remounts API 37 emulator, debuggable 0.88.1 Release 0 0

None of the registered helpers' ScrollViews is attached to a window or has a parent. The deleted ones are no longer in the surface's view registry. The retaining path is under Stacktrace or Logs.

Root cause

At b16032065:

On recreation, window teardown detaches only the views that are attached, and neither view deletion nor surface stop calls stop().

Not measured: the CPU cost of leftover helpers. FabricUIManager calls every listener on each mount (#L1009, #L1539, #L1548, #L1606). For this helper, willMountItems runs computeTargetView() and didMountItems runs updateScrollPositionInternal(); both return early when the ScrollView has no content view. didScheduleMountItems and didDispatchMountItems are no-ops.

Possible fixes

Two changes fix both results in my tests. Both diffs are against b16032065, and both are in the reproducer repo: https://github.com/nazar-pal/rn-mvcp-activity-leak-repro/blob/main/fix-stop-on-drop.diff and https://github.com/nazar-pal/rn-mvcp-activity-leak-repro/blob/main/fix-attach-check.diff.

1. Stop the helper when the view is dropped. Keep the prop setter as it is, and stop the helper in onDropViewInstance of the three scroll view managers. SurfaceMountingManager calls onDropViewInstance from both stopSurface and deleteView, through onViewStateDeleted. None of the three managers overrides it today.

Diff (fix-stop-on-drop.diff)
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt
@@ -74,6 +74,13 @@ constructor(private val fpsListener: FpsListener? = null) :
     return preparedView
   }
 
+  override fun onDropViewInstance(view: ReactHorizontalScrollView) {
+    // A view that was never attached to a window is never detached, so onDetachedFromWindow
+    // never stops its maintainVisibleContentPosition helper. Stop it when the view is dropped.
+    view.setMaintainVisibleContentPosition(null)
+    super.onDropViewInstance(view)
+  }
+
   override fun getName(): String = REACT_CLASS
 
   public override fun createViewInstance(context: ThemedReactContext): ReactHorizontalScrollView =
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt
@@ -83,6 +83,13 @@ constructor(private val fpsListener: FpsListener? = null) :
     return preparedView
   }
 
+  override fun onDropViewInstance(view: ReactNestedScrollView) {
+    // A view that was never attached to a window is never detached, so onDetachedFromWindow
+    // never stops its maintainVisibleContentPosition helper. Stop it when the view is dropped.
+    view.setMaintainVisibleContentPosition(null)
+    super.onDropViewInstance(view)
+  }
+
   override fun getName(): String = REACT_CLASS
 
   override fun createViewInstance(context: ThemedReactContext): ReactNestedScrollView =
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt
@@ -75,6 +75,13 @@ constructor(private val fpsListener: FpsListener? = null) :
     return preparedView
   }
 
+  override fun onDropViewInstance(view: ReactScrollView) {
+    // A view that was never attached to a window is never detached, so onDetachedFromWindow
+    // never stops its maintainVisibleContentPosition helper. Stop it when the view is dropped.
+    view.setMaintainVisibleContentPosition(null)
+    super.onDropViewInstance(view)
+  }
+
   override fun getName(): String = REACT_CLASS
 
   public override fun createViewInstance(context: ThemedReactContext): ReactScrollView =

2. Start the helper only when the view is attached. In the prop setter of the three scroll views, call start() only if isAttachedToWindow. onAttachedToWindow already starts it otherwise, and onDetachedFromWindow still stops it. The diff also updates the KDoc of start(), which says it should be called when the helper is created.

Diff (fix-attach-check.diff)
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt
@@ -69,7 +69,10 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
     }
   }
 
-  /** Start listening to view hierarchy updates. Should be called when this is created. */
+  /**
+   * Start listening to view hierarchy updates. Called while the scroll view is attached to a
+   * window, so that a view that is never attached is never registered.
+   */
   fun start() {
     if (isListening) {
       return
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt
@@ -441,7 +441,11 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
     if (config != null && maintainVisibleContentPositionHelper == null) {
       val helper = MaintainVisibleScrollPositionHelper(this, true)
       maintainVisibleContentPositionHelper = helper
-      helper.start()
+      // onAttachedToWindow starts the helper. Starting it here as well would register a view that
+      // is never attached with the UIManager, and nothing would ever unregister it.
+      if (isAttachedToWindow) {
+        helper.start()
+      }
     } else if (config == null) {
       maintainVisibleContentPositionHelper?.stop()
       maintainVisibleContentPositionHelper = null
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt
@@ -406,8 +406,13 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
       config: MaintainVisibleScrollPositionHelper.Config?,
   ) {
     if (config != null && maintainVisibleContentPositionHelper == null) {
-      maintainVisibleContentPositionHelper =
-          MaintainVisibleScrollPositionHelper(this, false).also { it.start() }
+      val helper = MaintainVisibleScrollPositionHelper(this, false)
+      maintainVisibleContentPositionHelper = helper
+      // onAttachedToWindow starts the helper. Starting it here as well would register a view that
+      // is never attached with the UIManager, and nothing would ever unregister it.
+      if (isAttachedToWindow) {
+        helper.start()
+      }
     } else if (config == null && maintainVisibleContentPositionHelper != null) {
       maintainVisibleContentPositionHelper?.stop()
       maintainVisibleContentPositionHelper = null
diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt
--- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt
+++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt
@@ -398,8 +398,13 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
       config: MaintainVisibleScrollPositionHelper.Config?,
   ) {
     if (config != null && maintainVisibleContentPositionHelper == null) {
-      maintainVisibleContentPositionHelper =
-          MaintainVisibleScrollPositionHelper(this, false).also { it.start() }
+      val helper = MaintainVisibleScrollPositionHelper(this, false)
+      maintainVisibleContentPositionHelper = helper
+      // onAttachedToWindow starts the helper. Starting it here as well would register a view that
+      // is never attached with the UIManager, and nothing would ever unregister it.
+      if (isAttachedToWindow) {
+        helper.start()
+      }
     } else if (config == null && maintainVisibleContentPositionHelper != null) {
       maintainVisibleContentPositionHelper?.stop()
       maintainVisibleContentPositionHelper = null

Measured on the API 37 emulator, Release with the prop (debuggable Release for the heap dumps). I tested each change by recompiling the changed classes against the official 0.88.1 react-android AAR and building the reproducer against that patched AAR; it was not a full source build.

Build Activities after 10 recreations, after 60 s idle, after trim Helpers in mListeners after 10 remounts Position check, left and right list
Official 0.88.1 11, 11, 11 11 0 px, 0 px
1. Stop on drop 2, 1, 1 1 (the live hidden ScrollView) 0 px, 0 px
2. Attach check 2, 1, 1 0 0 px, 0 px
1 and 2 together 2, 1, 1 0 0 px, 0 px
Official 0.88.1, lists without the prop 488 px, 487 px

With each fixed build, a heap dump after 4 recreations held 0 destroyed MainActivity. The position check is a small test app (tools/mvcp-behaviour-App.tsx in the repo): one list attached at mount (left), one inside a clipping parent that is attached later (right). Both are scrolled by about two rows, then five rows are prepended; 0 px means the same row stayed in place. The row positions were read from uiautomator dump bounds; that driver script is not in the repo.

Behaviour difference: with 1, a ScrollView that is mounted but never attached stays registered, and its helper keeps running on every mount, until the view is dropped. That is today's behaviour. With 2, such a ScrollView does not listen, so it does not adjust its scroll position while detached, as is already the case for a ScrollView that was attached and then detached.

I would propose 1 first. It fixes both results in these measurements without changing behaviour, and it matches the helper's own lifecycle as documented: start when created, stop "before this is destroyed". 2 is a separate choice about whether a never-attached view should listen at all; it can be added on top, and together they left 0 helpers.

Note on tests: the unit tests added in the open PRs #58187 and #58695 set the prop and then capture the listener from addUIManagerEventListener. #58187 never attaches its ReactScrollView, so with fix 2 that test would need to attach the view first (or call onAttachedToWindow). #58695 attaches it with activity.setContentView before setting the prop. Fix 1 does not change when the listener is added. I read these tests but did not run them.

Related: initView(), which view recycling calls, sets the helper to null without calling stop() (ReactScrollView.kt#L259, ReactHorizontalScrollView.kt#L292, ReactNestedScrollView.kt#L267). That only matters for a helper that is still registered when its view is recycled, and recycling (enableViewRecyclingForScrollView) is off by default. Fix 1 stops the helper before super.onDropViewInstance() reaches the recycling code. I did not test recycling, horizontal or nested scroll views at runtime (only compiled), or real devices.

I can open a PR with this change if that would help.

React Native Version

0.88.1

Affected Platforms

Runtime - Android, Build - MacOS

Areas

Fabric - The New Renderer

Output of npx @react-native-community/cli info
System:
  OS: macOS 27.2
  CPU: (15) arm64 Apple M5 Pro
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 22.23.2
  Yarn: Not Found
  npm:
    version: 10.9.8
  Watchman:
    version: 2026.06.22.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.16.2
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 27.2
      - iOS 27.2
      - macOS 27.2
      - tvOS 27.2
      - visionOS 27.2
      - watchOS 27.2
  Android SDK: Not Found
IDEs:
  Android Studio: 2026.2 AI-262.10968.63.2622.16730706
  Xcode:
    version: 27.2/27B52
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.19
    path: /usr/bin/javac
  Ruby:
    version: 2.6.10
    path: /usr/bin/ruby
npmPackages:
  "@react-native-community/cli":
    installed: 20.2.0
    wanted: 20.2.0
  react:
    installed: 19.3.0
    wanted: 19.3.0
  react-native:
    installed: 0.88.1
    wanted: 0.88.1
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: true
iOS:
  hermesEnabled: Not found
  newArchEnabled: false
Stacktrace or Logs
No crash in the reproducer. Retaining path after 4 recreations with the prop, from the repo's
tools/heap-check (Shark 2.14 HeapAnalyzer). It is the same on the API 37 emulator (debuggable
Release) and the API 29 emulator (official Release). Each destroyed Activity sits behind its own
entry of the listener array, one per leaked ScrollView.

MainActivity instances: 5 (destroyed: 4)
MaintainVisibleScrollPositionHelper instances: 5 (in FabricUIManager.mListeners: 5; their scroll views attached to a window: 0, with a parent: 0)

Shortest path from a GC root, the same for 4 destroyed MainActivity (array entries [0] [1] [2] [3]):
  GC root: JNI_GLOBAL
  com.facebook.react.fabric.FabricUIManager.mListeners
  java.util.concurrent.CopyOnWriteArrayList.array
  java.lang.Object[] [i]
  com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper.scrollView
  com.facebook.react.views.scroll.ReactScrollView.mContext
  com.facebook.react.uimanager.ThemedReactContext.mBase
  com.reproducerapp.MainActivity (mDestroyed = true)

Destroyed MainActivity reachable: 4
Destroyed MainActivity reachable with FabricUIManager.mListeners ignored: 0
MANDATORY Reproducer

https://github.com/nazar-pal/rn-mvcp-activity-leak-repro

Screenshots and Videos

Not visual: the leaked ScrollView is never on screen.

I used an AI assistant (Claude) to investigate this and to write the report. The measurements were taken on the simulators and emulators described above.

Langage dominant
C++
Étoiles
127k
Forks
25.5k
Merge moyen
3 j 18 h
PR mergées (30 j)
188

Préparer son environnement

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de react/react-native

Toutes les issues de react/react-native

Issues similaires

Plus d'issues C++

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.