fix(android): remove the accessibility layout listener on detach - #731
Conversation
MarkdownAccessibilityHelper defers its item rebuild by registering an OnGlobalLayoutListener, and only the listener's own callback ever removes it. When that callback does not run before the view goes away, the listener stays registered on ViewRootImpl's ViewTreeObserver, which outlives the screen. It holds the helper, which holds the TextView, which holds its parent, so a single markdown view retains the whole hierarchy it was mounted in. Removing it in onDetachedFromWindow is what makes the removal land on the right observer: getViewTreeObserver() returns the window's observer only while the view is attached, and afterwards hands back the view's own floating one. The cleanup goes in AccessibleMarkdownTextView because that is where the helper is created, so EnrichedMarkdownInternalText is covered by the same change. Measured on a release build (Pixel 11 Pro, Android 16, RN 0.86.2, Fabric), six push/pop cycles of a screen containing one EnrichedMarkdownText, Views read from dumpsys meminfo after forcing a GC: 327 -> 1907 before, 327 -> 327 after.
|
Two follow-ups, both of which I would have wanted answered if I were reviewing this. 1. Why TalkBack is unaffected, from the code rather than from my assertion.
It cannot get stuck, because
So items are built lazily on demand and 2. The measurement that shows the listener is the retainer, not a correlate. The variant I mentioned in the description, an
That is the tightest form of the argument. A component that renders nothing still leaks, so the |
eszlamczyk
left a comment
There was a problem hiding this comment.
Hi @Tyler-V thanks for the genuinely thorough investigation!
I reproduced the leak and confirmed the fix on our side. Thanks for your contribution.
One small thing for next time: this repo carries a second, parallel native source tree at packages/android-enriched-markdown/, and it has the same leaking listener with no detach cleanup, so a change like this generally needs to be mirrored there too. If you could in the future just run a agent to check, wether this fix needs to be applied as well there. I will fix this myself to speed it up, but please be aware in the future.
Thanks again for the high-quality report and fix!
What/Why?
Fixes #730.
On Android, an
EnrichedMarkdownTextthat is mounted and later unmounted retains its entireenclosing view hierarchy, permanently.
MarkdownAccessibilityHelperdefers its TalkBack itemrebuild by registering a
ViewTreeObserver.OnGlobalLayoutListener, and that listener is only everremoved by its own callback:
enriched-markdown/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt
Lines 78 to 101 in 00423aa
Three things make that leak once per instance:
accessibilityLabels, so thelabelssetter callsinvalidateAccessibilityItems()atprop-set time, when
textView.layoutis stillnull. Every instance registers a listener.so
textView.viewTreeObserveris the view's own floating observer;View.dispatchAttachedToWindowthen merges it intoAttachInfo.mTreeObserver, which belongsto
ViewRootImpland outlives every screen.removePendingLayoutListener()has exactly one caller, inside thelistener. The immediate branch of
invalidateAccessibilityItems()never clears a listenerregistered earlier, and neither detach nor
onDropViewInstancedoes.Result:
ViewRootImpl.mTreeObserver -> listener -> helper -> TextView -> mParent -> the whole screen.onDetachedFromWindowis the right place for the removal specifically becausegetViewTreeObserver()returns the window's observer only while the view is attached. Doing itfrom
onDropViewInstancewould silently remove from the view's floating observer and leave theleak untouched.
The cleanup goes in
AccessibleMarkdownTextViewrather than inEnrichedMarkdownText, becausethat is where the helper is created, so
EnrichedMarkdownInternalText(and with it theEnrichedMarkdowncomponent) is covered by the same change.Testing
Measured in a production app, on a release build with R8 enabled: Pixel 11 Pro, Android 16,
React Native 0.86.2 on the New Architecture, expo-router / react-native-screens navigation.
Six push/pop cycles of one screen,
ViewsandWebViewsread fromdumpsys meminfo's Objectsblock after forcing a GC by backgrounding the app, two samples per reading:
The
WebViewscolumn is not a typo: an unrelatedWebViewelsewhere on the same screen was beingretained through the same chain, which is what makes this expensive rather than merely untidy. At
about 23 MB PSS per retained screen, this was the whole of a leak we had been chasing for two
weeks.
Two A/Bs isolate it to this component and this mechanism, same install and same content:
<Text>returns to baseline exactly.markdown="", which returns fromsetMarkdownContentbeforescheduleRenderand never parses or renders anything, leaks at the identical rate. Thatrules out the parser, the spans,
MeasurementStoreand the render executor, and points atconstruction and prop-set, which is where the listener is registered.
One incidental observation while measuring: the per-view
Executors.newSingleThreadExecutor()inEnrichedMarkdownTextclimbed about 4 threads per screen open before this change and goes flatafter it. That is a symptom rather than a second bug (the pool's
finalize()shuts it down oncethe view is collectable), but it does mean a retained view also costs a live thread.
PR Checklist
Being straight about the unticked boxes: the change is Kotlin-only and Android-only, and it was
built, run and measured in a real release app rather than in this repo's example app, so I have
not run the Maestro suites or an iOS build. I did not add an E2E test because the behaviour is not
user-visible - it is an object count in
dumpsys meminfo, which Maestro cannot assert. Happy toadjust the shape of this if you would rather the cleanup lived somewhere else, and happy to put
together a standalone reproduction repo if that would help.