Skip to content

[Android] Unmounted TextInput with secureTextEntry is never released: TextLayoutManager's spannable cache keeps it (and its Activity) alive #59469

Description

@A-Kimpo

Description

We upgraded our app to 0.87 and noticed that every sign-in screen stayed in memory. A heap dump led to TextLayoutManager.tagToSpannableCache. It reproduces on 0.88.0 too, in a fresh app with no other dependencies.

On Android (Fabric), a <TextInput secureTextEntry /> that received input is never released after it is unmounted. Its ancestors and the typed text stay with it, and after the Activity is recreated the old Activity stays as well.

Why:

  1. ReactEditText.updateCachedSpannable() copies the text with all its spans into tagToSpannableCache.
  2. A password field's Editable carries PasswordTransformationMethod.Visible while the last typed character is shown. It is not a NoCopySpan, so it gets copied, and it references the Editable.
  3. The Editable's TextView.ChangeWatcher references the ReactEditText, whose context leads to the Activity.
  4. The entry is evicted only in ReactEditText.finalize(), which can't run while the cache holds the view.

#58869 is a different problem (tag reuse across reloads) and keeps eviction in finalize(), so it doesn't fix this.

Steps to reproduce

Reproducer: https://github.com/A-Kimpo/rn-secure-textinput-leak (release build with debuggable true, emulator API 34 arm64).

  1. Type into the password field, unmount it, mount a new one (new key). Repeat 5 times.
  2. Heap dump after GC: adb shell am dumpheap -g <pid> /data/local/tmp/x.hprof.
  3. Count strongly reachable ReactEditText.

./repro.sh secure 5, ./repro.sh plain 5 and ./repro.sh secure 5 recreate do all of this and print the retention paths.

Run (0.88.0) ReactEditText MainActivity
secureTextEntry, 5 cycles 6 (1 mounted + 5 leaked) 1
plain TextInput, 5 cycles 1 1
secureTextEntry, 5 cycles, then Activity recreated 7 2
same, RN built from source with the fix below 1 1

Same numbers on 0.87.1. RN built from source without the fix leaks the same way as the official artifacts. More runs are in the reproducer's README.

Retention path of the old Activity:

com.leakrepro.MainActivity
  ← ThemedReactContext.mBase
  ← ReactEditText.mContext
  ← TextView$ChangeWatcher.this$0
  ← androidx.emoji2.text.SpannableBuilder.mSpans          (the EditText's Editable)
  ← PasswordTransformationMethod$Visible.mText
  ← SpannableStringBuilder.mSpans                         (the cached copy)
  ← ConcurrentHashMap$Node.val
  ← static TextLayoutManager.tagToSpannableCache

Suggested fix

The cached copy only feeds measurement, so editor bookkeeping spans don't need to be in it:

       try {
         sb.append(currentText.subSequence(0, currentText.length))
       } catch (e: IndexOutOfBoundsException) {
         logSoftException(TAG, e)
       }
+      // The copy only feeds measurement. Editor bookkeeping spans must not come along: some of them
+      // reference this view's Editable (PasswordTransformationMethod.Visible), and the copy lives in a
+      // static cache that is evicted only from finalize(), so the view would never become unreachable.
+      stripSpansOfKind(sb, Any::class.java) { span ->
+        span !is CharacterStyle && span !is ParagraphStyle && span !is ReactSpan
+      }

Plus imports of android.text.style.CharacterStyle and android.text.style.ParagraphStyle.

I built 0.88.0 from source with and without this change: with it the leak is gone, without it the leak is there. An auto-growing multiline TextInput, measured from this cache, has the same height either way (48 dp empty, 104 dp with four lines). I did not run RN's own unit tests or check inline images / nested text in a TextInput.

Workaround until it's fixed: clear the field in a useLayoutEffect cleanup, so the cache gets an empty copy before the views are deleted (a navigation blur listener isn't enough: a screen removed by a reset gets no blur).

useLayoutEffect(() => {
  if (!secureTextEntry || Platform.OS !== 'android') return undefined;
  const input = inputRef.current;
  return () => input?.clear();
}, [secureTextEntry]);

React Native Version

0.88.0 (also 0.87.1)

Affected Platforms

Runtime - Android

Areas

Fabric - The New Renderer

Output of npx @react-native-community/cli info

info Fetching system and libraries information...
System:
  OS: macOS 26.5
  CPU: (12) arm64 Apple M4 Pro
  Memory: 203.81 MB / 24.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 24.22.0
    path: ~/.local/share/nvm/v24.22.0/bin/node
  Yarn: Not Found
  npm:
    version: 11.19.1
    path: ~/.local/share/nvm/v24.22.0/bin/npm
  Watchman:
    version: 2026.10.12.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.16.2
    path: ~/.rbenv/shims/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.2
      - iOS 26.2
      - macOS 26.2
      - tvOS 26.2
      - visionOS 26.2
      - watchOS 26.2
  Android SDK:
    API Levels:
      - "34"
      - "35"
      - "36"
      - "37"
    Build Tools:
      - 35.0.0
      - 36.0.0
      - 37.0.0
    System Images:
      - android-34 | Google APIs ARM 64 v8a
    Android NDK: Not Found
IDEs:
  Android Studio: 2025.1 AI-251.26094.121.2513.14007798
  Xcode:
    version: 26.2/17C52
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.20.1
    path: /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin/javac
  Ruby:
    version: 3.3.5
    path: ~/.rbenv/shims/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.0
    wanted: 0.88.0
  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. The evidence is the retention path above.

MANDATORY Reproducer

https://github.com/A-Kimpo/rn-secure-textinput-leak

Screenshots and Videos

Not applicable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions