Skip to content

FirstDrawDoneListener leaks an OnGlobalLayoutListener per registration, pinning the watched view tree (unbounded per-navigation leak in single-Activity apps) #5495

@llamington

Description

@llamington

Integration

sentry-android

Build System

Gradle

AGP Version

8.11.0

Proguard

Disabled

Other Error Monitoring Solution

No

Version

8.29.0 (code unchanged on main as of this report)

Steps to Reproduce

Every call to FirstDrawDoneListener.registerForNextDraw(...) permanently leaks one OnGlobalLayoutListener on the watched window's ViewTreeObserver, strongly retaining the object graph captured by the supplied callback.

In our app the caller is @sentry/react-native 7.4.0's time-to-initial-display instrumentation (RNSentryOnDrawReporterManagerFirstDrawDoneListener.registerForNextDraw(activity, …)), which registers once per navigation. React Native apps are single-Activity, so the decor view's ViewTreeObserver lives for the whole session and the leaked listeners accumulate without bound:

  1. React Native app with reactNavigationIntegration({ enableTimeToInitialDisplay: true })
  2. Navigate between screens N times
  3. Heap dump → inspect the decor view's ViewTreeObserver.mOnGlobalLayoutListeners
  4. N FirstDrawDoneListener$$ExternalSyntheticLambda0 entries, each pinning a dismissed screen's view tree

Expected Result

After the first draw completes and the OnDrawListener has been removed, nothing remains registered on the ViewTreeObserver, and the object graph captured by the supplied callback becomes eligible for collection.

Actual Result

onDraw() registers a cleanup OnGlobalLayoutListener to remove the OnDrawListener (which can't be removed during draw dispatch), but that cleanup listener is never itself unregistered (FirstDrawDoneListener.java):

@Override
public void onDraw() {
  final View view = viewReference.getAndSet(null);
  if (view == null) {
    return;
  }
  // OnDrawListeners cannot be removed within onDraw, so we remove it with a
  // GlobalLayoutListener
  view.getViewTreeObserver()
      .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this));
  mainThreadHandler.postAtFrontOfQueue(callback);
}

The lambda captures view and this; this.callback (a final field, never cleared) is the caller's Runnable, which in the RN case transitively references the dismissed screen.

MAT evidence from a production-equivalent build after ~50 navigations to a screen containing a Google MapView (GC-root path, weak/soft excluded):

android.view.inputmethod.InputMethodManager$2 (JNI Global)
└─ DecorView → ViewTreeObserver → mOnGlobalLayoutListeners (CopyOnWriteArray)
   └─ 53× io.sentry.android.core.internal.util.FirstDrawDoneListener$$ExternalSyntheticLambda0
      └─ f$1: com.swmansion.rnscreens ScreensCoordinatorLayout (captured view)
         └─ ScreenStackFragment → Screen → … → ReactViewGroup → … → com.google.android.gms.maps.MapView
  • Listener count tracked navigations 1:1 (30 after ~30 navigations, 53 after ~50).
  • ~80–100 MB of Java heap retained by the pinned screens in our app at the 50-navigation mark.
  • Control: disabling enableTimeToInitialDisplay returns retained screen counts to ~1 and the Java heap stays flat over the same workload.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions