Skip to content

fix(android): remove the accessibility layout listener on detach - #731

Merged
eszlamczyk merged 2 commits into
software-mansion:mainfrom
Tyler-V:fix/android-accessibility-layout-listener-leak
Aug 31, 2026
Merged

fix(android): remove the accessibility layout listener on detach#731
eszlamczyk merged 2 commits into
software-mansion:mainfrom
Tyler-V:fix/android-accessibility-layout-listener-leak

Conversation

@Tyler-V

@Tyler-V Tyler-V commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

What/Why?

Fixes #730.

On Android, an EnrichedMarkdownText that is mounted and later unmounted retains its entire
enclosing view hierarchy
, permanently. MarkdownAccessibilityHelper defers its TalkBack item
rebuild by registering a ViewTreeObserver.OnGlobalLayoutListener, and that listener is only ever
removed by its own callback:

private fun schedulePostLayoutRebuild() {
if (pendingLayoutListener != null) return
val observer = textView.viewTreeObserver
if (!observer.isAlive) return
val listener =
ViewTreeObserver.OnGlobalLayoutListener {
removePendingLayoutListener()
if (needsRebuild) {
rebuildIfNeeded()
invalidateRoot()
}
}
pendingLayoutListener = listener
observer.addOnGlobalLayoutListener(listener)
}
private fun removePendingLayoutListener() {
val listener = pendingLayoutListener ?: return
pendingLayoutListener = null
val observer = textView.viewTreeObserver
if (observer.isAlive) {
observer.removeOnGlobalLayoutListener(listener)
}
}

Three things make that leak once per instance:

  1. The deferred branch is the normal path. The JS wrapper always sends resolved
    accessibilityLabels, so the labels setter calls invalidateAccessibilityItems() at
    prop-set time, when textView.layout is still null. Every instance registers a listener.
  2. The listener ends up on the window's observer. At prop-set time the view is not attached,
    so textView.viewTreeObserver is the view's own floating observer;
    View.dispatchAttachedToWindow then merges it into AttachInfo.mTreeObserver, which belongs
    to ViewRootImpl and outlives every screen.
  3. Nothing else removes it. removePendingLayoutListener() has exactly one caller, inside the
    listener. The immediate branch of invalidateAccessibilityItems() never clears a listener
    registered earlier, and neither detach nor onDropViewInstance does.

Result: ViewRootImpl.mTreeObserver -> listener -> helper -> TextView -> mParent -> the whole screen.

onDetachedFromWindow is the right place for the removal specifically because
getViewTreeObserver() returns the window's observer only while the view is attached. Doing it
from onDropViewInstance would silently remove from the view's floating observer and leave the
leak untouched.

The cleanup goes in AccessibleMarkdownTextView rather than in EnrichedMarkdownText, because
that is where the helper is created, so EnrichedMarkdownInternalText (and with it the
EnrichedMarkdown component) 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, Views and WebViews read from dumpsys meminfo's Objects
block after forcing a GC by backgrounding the app, two samples per reading:

Six push/pop cycles Views WebViews
Before 327 -> 1907 (+225/cycle) 0 -> 7
After 327 -> 327 0 -> 0

The WebViews column is not a typo: an unrelated WebView elsewhere on the same screen was being
retained 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:

  • Replacing the markdown component with a plain <Text> returns to baseline exactly.
  • A component rendered with markdown="", which returns from setMarkdownContent before
    scheduleRender and never parses or renders anything, leaks at the identical rate. That
    rules out the parser, the spans, MeasurementStore and the render executor, and points at
    construction and prop-set, which is where the listener is registered.

One incidental observation while measuring: the per-view Executors.newSingleThreadExecutor() in
EnrichedMarkdownText climbed about 4 threads per screen open before this change and goes flat
after it. That is a symptom rather than a second bug (the pool's finalize() shuts it down once
the view is collectable), but it does mean a retained view also costs a live thread.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

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 to
adjust 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.

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.
@Tyler-V

Tyler-V commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

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.

cleanup() clears pendingLayoutListener but deliberately leaves needsRebuild alone, so the
obvious worry is a view that detaches before its listener fires and re-attaches with
needsRebuild = true and nothing left to service it.

It cannot get stuck, because rebuildIfNeeded() is the first statement of every path TalkBack
actually queries through:

  • getVirtualViewAt (MarkdownAccessibilityHelper.kt:258)
  • getVisibleVirtualViews (:273)
  • onPopulateNodeForHost (:279)

So items are built lazily on demand and needsRebuild surviving a detach is harmless. The
listener is a proactive optimisation - it rebuilds early and calls invalidateRoot() so TalkBack
is notified of the change - not the only path to building items. Removing it on detach costs a
detached view an early rebuild it has no use for.

2. The measurement that shows the listener is the retainer, not a correlate.

The variant I mentioned in the description, an EnrichedMarkdownText rendered with markdown=""
so setMarkdownContent returns before scheduleRender and nothing is ever parsed or rendered,
leaks at the same rate as a fully rendered one. I have now also run it with this patch, same
build, same six push/pop cycles:

markdown="", six cycles Views WebViews
Before 327 -> 807 (+80/cycle) 0 -> 6
After 327 -> 327 0 -> 0

That is the tightest form of the argument. A component that renders nothing still leaks, so the
parser, the spans, MeasurementStore and the render executor are all out; and this patch fixes
that same component, so what remains is registration at prop-set time. It also settles a detail I
had only inferred from the JS wrapper always sending resolved accessibilityLabels:
invalidateAccessibilityItems() really does run on the prop path even for empty markdown,
because otherwise there would be no listener for this change to remove and the numbers could not
have moved.

@eszlamczyk eszlamczyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@eszlamczyk
eszlamczyk merged commit 2841255 into software-mansion:main Aug 31, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] EnrichedMarkdownText retains its whole view hierarchy: the accessibility OnGlobalLayoutListener is never removed

2 participants