Repository navigation
[Android] Unmounted TextInput with secureTextEntry is never released: TextLayoutManager's spannable cache keeps it (and its Activity) alive #59469
Description
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:
ReactEditText.updateCachedSpannable()copies the text with all its spans intotagToSpannableCache.- A password field's Editable carries
PasswordTransformationMethod.Visiblewhile the last typed character is shown. It is not aNoCopySpan, so it gets copied, and it references the Editable. - The Editable's
TextView.ChangeWatcherreferences theReactEditText, whose context leads to the Activity. - 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).
- Type into the password field, unmount it, mount a new one (new
key). Repeat 5 times. - Heap dump after GC:
adb shell am dumpheap -g <pid> /data/local/tmp/x.hprof. - 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.
Activity