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 (RNSentryOnDrawReporterManager → FirstDrawDoneListener.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:
- React Native app with
reactNavigationIntegration({ enableTimeToInitialDisplay: true })
- Navigate between screens N times
- Heap dump → inspect the decor view's
ViewTreeObserver.mOnGlobalLayoutListeners
- 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.
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
mainas of this report)Steps to Reproduce
Every call to
FirstDrawDoneListener.registerForNextDraw(...)permanently leaks oneOnGlobalLayoutListeneron the watched window'sViewTreeObserver, strongly retaining the object graph captured by the supplied callback.In our app the caller is
@sentry/react-native7.4.0's time-to-initial-display instrumentation (RNSentryOnDrawReporterManager→FirstDrawDoneListener.registerForNextDraw(activity, …)), which registers once per navigation. React Native apps are single-Activity, so the decor view'sViewTreeObserverlives for the whole session and the leaked listeners accumulate without bound:reactNavigationIntegration({ enableTimeToInitialDisplay: true })ViewTreeObserver.mOnGlobalLayoutListenersFirstDrawDoneListener$$ExternalSyntheticLambda0entries, each pinning a dismissed screen's view treeExpected Result
After the first draw completes and the
OnDrawListenerhas been removed, nothing remains registered on theViewTreeObserver, and the object graph captured by the supplied callback becomes eligible for collection.Actual Result
onDraw()registers a cleanupOnGlobalLayoutListenerto remove theOnDrawListener(which can't be removed during draw dispatch), but that cleanup listener is never itself unregistered (FirstDrawDoneListener.java):The lambda captures
viewandthis;this.callback(afinalfield, never cleared) is the caller'sRunnable, 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):
enableTimeToInitialDisplayreturns retained screen counts to ~1 and the Java heap stays flat over the same workload.