Skip to content

Commit 307162b

Browse files
Autopilot Botmeta-codesync[bot]
authored andcommitted
Scope the TextInput spannable cache to the React instance (#58869)
Summary: WARNING: Generated by Autopilot (alpha) — review carefully, verify the underlying claim before accepting. Agent: React Native Agent (Bugs) | Trajectory: https://www.internalfb.com/intern/devai/devmate/inspector/dea12476-b7e6-441b-8ebb-bd79b2de520a/ | SC job: https://www.internalfb.com/intern/sandcastle/instance/49539596621165635/ --- Pull Request resolved: #58869 On Fabric, `ReactEditText` caches its spannable in `TextLayoutManager` keyed by react tag, and the shadow node's state keeps that tag as `cachedAttributedStringId` to re-measure against. The cache was a single process-global map keyed only by tag. React tags restart for every new React instance, so after a reload the old and new instances can use the same tags in the same map: the new instance can read the old instance's spannable, and when an old `ReactEditText` is finalized it removes the new view's entry. The next cached measure then fails `checkNotNull` in `getOrCreateSpannableForText` with "Required value was null". This change gives each React instance its own spannable cache: - `TextLayoutManager` keeps one map per `ReactApplicationContext`. `FabricUIManager` creates it in its constructor and removes it in `invalidate()`. - `FabricUIManager.measureText` passes its own context, so a cached measure only looks in that instance's map. - `ReactEditText` writes to and evicts from the map of the context it was created with (`ThemedReactContext.reactApplicationContext`). Create, destroy, read and write all resolve the key through one helper, so a view's `ThemedReactContext` and `FabricUIManager`'s `ReactApplicationContext` always hit the same map. After teardown, an old view's set or evict does nothing because its map is gone. - A layout that started before `invalidate()` can still reach `measureText` after the map is removed. In that case the cached measure returns a 0x0 measurement instead of throwing, because the cache-id `MapBuffer` carries only the id and there is nothing to rebuild from. The result belongs to an instance that is going away. A missing entry in a live map still fails `checkNotNull`. This replaces the previous version of this diff, where `finalize()` only evicted the spannable the instance itself had cached. Per-tag eviction still happens in `finalize()`, but now only within the view's own instance, where tags are never reused. I did not move eviction to `onDropViewInstance`: a commit whose layout is still running on a background thread can measure a TextInput after the UI thread has already processed that view's delete, so evicting right away would add a new way to hit the same `checkNotNull`. Changelog: [Android][Fixed] - Fix TextInput measurement crash ("Required value was null") after a React instance reload, by scoping the cached TextInput spannables to the React instance Reviewed By: zeyap Differential Revision: D122870300
1 parent 99d327c commit 307162b

4 files changed

Lines changed: 281 additions & 40 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ public FabricUIManager(
259259

260260
mViewManagerRegistry = viewManagerRegistry;
261261
mReactApplicationContext.registerComponentCallbacks(viewManagerRegistry);
262+
TextLayoutManager.createSpannableCache(mReactApplicationContext);
262263
}
263264

264265
@Override
@@ -496,6 +497,7 @@ public void invalidate() {
496497
}
497498
mBinding = null;
498499

500+
TextLayoutManager.destroySpannableCache(mReactApplicationContext);
499501
ViewManagerPropertyUpdater.clear();
500502
}
501503

@@ -566,7 +568,8 @@ private NativeArray measureLines(
566568
textViewManager instanceof ReactTextViewManagerCallback
567569
? (ReactTextViewManagerCallback) textViewManager
568570
: null,
569-
mTextEffectRegistry);
571+
mTextEffectRegistry,
572+
mReactApplicationContext);
570573
}
571574

572575
public @Nullable Integer getColor(int surfaceId, String[] resourcePaths) {
@@ -658,7 +661,8 @@ public long measureText(
658661
? (ReactTextViewManagerCallback) textViewManager
659662
: null,
660663
attachmentsPositions,
661-
mTextEffectRegistry);
664+
mTextEffectRegistry,
665+
mReactApplicationContext);
662666
}
663667

664668
@AnyThread

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt‎

Lines changed: 72 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ import android.view.View
2828
import androidx.annotation.VisibleForTesting
2929
import com.facebook.common.logging.FLog
3030
import com.facebook.infer.annotation.Assertions
31+
import com.facebook.react.bridge.Arguments
3132
import com.facebook.react.bridge.JavaOnlyArray
3233
import com.facebook.react.bridge.JavaOnlyMap
34+
import com.facebook.react.bridge.ReactContext
3335
import com.facebook.react.bridge.ReadableMap
3436
import com.facebook.react.bridge.WritableArray
3537
import com.facebook.react.common.ReactConstants
@@ -42,6 +44,7 @@ import com.facebook.react.uimanager.PixelUtil
4244
import com.facebook.react.uimanager.PixelUtil.dpToPx
4345
import com.facebook.react.uimanager.PixelUtil.pxToDp
4446
import com.facebook.react.uimanager.ReactAccessibilityDelegate
47+
import com.facebook.react.uimanager.ThemedReactContext
4548
import com.facebook.react.util.AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM
4649
import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan
4750
import com.facebook.react.views.text.internal.span.CustomLineHeightSpan
@@ -115,7 +118,11 @@ internal object TextLayoutManager {
115118

116119
private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line"
117120

118-
private val tagToSpannableCache = ConcurrentHashMap<Int, Spannable>()
121+
// TextInput spannables keyed by react tag, one map per React instance. Tags restart from the same
122+
// value in every instance, so entries must not be shared across instances. FabricUIManager
123+
// creates and destroys the map for its ReactApplicationContext.
124+
private val tagToSpannableCaches =
125+
ConcurrentHashMap<ReactContext, ConcurrentHashMap<Int, Spannable>>()
119126

120127
// These wrappers mirror Android 15 APIs but use reflection because some internal targets still
121128
// compile against Android 14. They return null when the API is unavailable or cannot be invoked.
@@ -180,12 +187,35 @@ internal object TextLayoutManager {
180187
null
181188
}
182189

183-
fun setCachedSpannableForTag(reactTag: Int, sp: Spannable) {
184-
tagToSpannableCache[reactTag] = sp
190+
// Views hold a ThemedReactContext while FabricUIManager holds the ReactApplicationContext it
191+
// wraps. Every cache access goes through this so both resolve to the same key.
192+
private fun spannableCacheKey(reactContext: ReactContext): ReactContext =
193+
if (reactContext is ThemedReactContext) reactContext.reactApplicationContext else reactContext
194+
195+
@JvmStatic
196+
fun createSpannableCache(reactContext: ReactContext) {
197+
tagToSpannableCaches[spannableCacheKey(reactContext)] = ConcurrentHashMap()
198+
}
199+
200+
@JvmStatic
201+
fun destroySpannableCache(reactContext: ReactContext) {
202+
tagToSpannableCaches.remove(spannableCacheKey(reactContext))
203+
}
204+
205+
private fun getSpannableCache(reactContext: ReactContext): ConcurrentHashMap<Int, Spannable>? =
206+
tagToSpannableCaches[spannableCacheKey(reactContext)]
207+
208+
// Returns null once FabricUIManager.invalidate() has removed the instance's map. A missing entry
209+
// in a live map is an eviction bug, so it still fails.
210+
internal fun getCachedSpannable(reactContext: ReactContext, reactTag: Int): Spannable? =
211+
getSpannableCache(reactContext)?.let { checkNotNull(it[reactTag]) }
212+
213+
fun setCachedSpannableForTag(reactContext: ReactContext, reactTag: Int, sp: Spannable) {
214+
getSpannableCache(reactContext)?.put(reactTag, sp)
185215
}
186216

187-
fun deleteCachedSpannableForTag(reactTag: Int) {
188-
tagToSpannableCache.remove(reactTag)
217+
fun deleteCachedSpannableForTag(reactContext: ReactContext, reactTag: Int) {
218+
getSpannableCache(reactContext)?.remove(reactTag)
189219
}
190220

191221
fun isRTL(attributedString: MapBuffer): Boolean {
@@ -765,25 +795,15 @@ internal object TextLayoutManager {
765795
attributedString: MapBuffer,
766796
reactTextViewManagerCallback: ReactTextViewManagerCallback?,
767797
textEffectRegistry: TextEffectRegistry?,
768-
): Spannable {
769-
val text: Spannable?
770-
if (attributedString.contains(AS_KEY_CACHE_ID)) {
771-
val cacheId = attributedString.getInt(AS_KEY_CACHE_ID)
772-
text = checkNotNull(tagToSpannableCache[cacheId])
773-
} else {
774-
text =
775-
createSpannableFromAttributedString(
776-
assets,
777-
fontWeightAdjustment,
778-
attributedString.getMapBuffer(AS_KEY_FRAGMENTS),
779-
reactTextViewManagerCallback,
780-
null,
781-
textEffectRegistry,
782-
)
783-
}
784-
785-
return text
786-
}
798+
): Spannable =
799+
createSpannableFromAttributedString(
800+
assets,
801+
fontWeightAdjustment,
802+
attributedString.getMapBuffer(AS_KEY_FRAGMENTS),
803+
reactTextViewManagerCallback,
804+
null,
805+
textEffectRegistry,
806+
)
787807

788808
@OptIn(UnstableReactNativeAPI::class)
789809
private fun createSpannableFromAttributedString(
@@ -1091,20 +1111,28 @@ internal object TextLayoutManager {
10911111
heightYogaMeasureMode: YogaMeasureMode,
10921112
reactTextViewManagerCallback: ReactTextViewManagerCallback?,
10931113
textEffectRegistry: TextEffectRegistry? = null,
1094-
): Layout {
1095-
val text =
1096-
getOrCreateSpannableForText(
1097-
assets,
1098-
fontWeightAdjustment,
1099-
attributedString,
1100-
reactTextViewManagerCallback,
1101-
textEffectRegistry,
1102-
)
1103-
1114+
spannableCacheOwner: ReactContext? = null,
1115+
): Layout? {
1116+
val text: Spannable
11041117
val paint: TextPaint
11051118
if (attributedString.contains(AS_KEY_CACHE_ID)) {
1119+
// A layout that started before FabricUIManager.invalidate() can still measure here after the
1120+
// instance's cache is gone. There is nothing to measure, and the result is discarded. Without
1121+
// an owner there is no cache to read either.
1122+
text =
1123+
spannableCacheOwner?.let {
1124+
getCachedSpannable(it, attributedString.getInt(AS_KEY_CACHE_ID))
1125+
} ?: return null
11061126
paint = text.getSpans(0, 0, ReactTextPaintHolderSpan::class.java)[0].textPaint
11071127
} else {
1128+
text =
1129+
getOrCreateSpannableForText(
1130+
assets,
1131+
fontWeightAdjustment,
1132+
attributedString,
1133+
reactTextViewManagerCallback,
1134+
textEffectRegistry,
1135+
)
11081136
val baseTextAttributes =
11091137
TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES))
11101138
paint = scratchPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment)
@@ -1463,6 +1491,7 @@ internal object TextLayoutManager {
14631491
reactTextViewManagerCallback: ReactTextViewManagerCallback?,
14641492
attachmentsPositions: FloatArray?,
14651493
textEffectRegistry: TextEffectRegistry? = null,
1494+
spannableCacheOwner: ReactContext? = null,
14661495
): Long =
14671496
measureText(
14681497
assets,
@@ -1476,6 +1505,7 @@ internal object TextLayoutManager {
14761505
reactTextViewManagerCallback,
14771506
attachmentsPositions,
14781507
textEffectRegistry,
1508+
spannableCacheOwner,
14791509
)
14801510

14811511
@JvmStatic
@@ -1492,6 +1522,7 @@ internal object TextLayoutManager {
14921522
reactTextViewManagerCallback: ReactTextViewManagerCallback?,
14931523
attachmentsPositions: FloatArray?,
14941524
textEffectRegistry: TextEffectRegistry? = null,
1525+
spannableCacheOwner: ReactContext? = null,
14951526
): Long {
14961527
// TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic)
14971528
val layout =
@@ -1506,7 +1537,8 @@ internal object TextLayoutManager {
15061537
heightYogaMeasureMode,
15071538
reactTextViewManagerCallback,
15081539
textEffectRegistry,
1509-
)
1540+
spannableCacheOwner,
1541+
) ?: return YogaMeasureOutput.make(0f, 0f)
15101542

15111543
val maximumNumberOfLines =
15121544
if (paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES))
@@ -1775,6 +1807,7 @@ internal object TextLayoutManager {
17751807
height: Float,
17761808
reactTextViewManagerCallback: ReactTextViewManagerCallback?,
17771809
textEffectRegistry: TextEffectRegistry? = null,
1810+
spannableCacheOwner: ReactContext? = null,
17781811
): WritableArray =
17791812
measureLines(
17801813
assetManager,
@@ -1785,6 +1818,7 @@ internal object TextLayoutManager {
17851818
height,
17861819
reactTextViewManagerCallback,
17871820
textEffectRegistry,
1821+
spannableCacheOwner,
17881822
)
17891823

17901824
@JvmStatic
@@ -1798,6 +1832,7 @@ internal object TextLayoutManager {
17981832
height: Float,
17991833
reactTextViewManagerCallback: ReactTextViewManagerCallback?,
18001834
textEffectRegistry: TextEffectRegistry? = null,
1835+
spannableCacheOwner: ReactContext? = null,
18011836
): WritableArray {
18021837
val layout =
18031838
createLayoutForMeasurement(
@@ -1811,7 +1846,8 @@ internal object TextLayoutManager {
18111846
YogaMeasureMode.EXACTLY,
18121847
reactTextViewManagerCallback,
18131848
textEffectRegistry,
1814-
)
1849+
spannableCacheOwner,
1850+
) ?: return Arguments.createArray()
18151851
return FontMetricsUtil.getFontMetrics(
18161852
layout.text,
18171853
layout,

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package com.facebook.react.views.textinput
99

1010
import android.annotation.SuppressLint
1111
import android.content.Context
12+
import android.content.ContextWrapper
1213
import android.content.res.Configuration
1314
import android.graphics.Canvas
1415
import android.graphics.Color
@@ -46,6 +47,7 @@ import androidx.core.graphics.withTranslation
4647
import androidx.core.util.Predicate
4748
import androidx.core.view.ViewCompat
4849
import com.facebook.common.logging.FLog
50+
import com.facebook.react.bridge.ReactContext
4951
import com.facebook.react.bridge.ReactSoftExceptionLogger.logSoftException
5052
import com.facebook.react.common.ReactConstants
5153
import com.facebook.react.common.assets.ReactFontManager
@@ -292,7 +294,14 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
292294
if (DEBUG_MODE) {
293295
FLog.e(TAG, "finalize[$id] delete cached spannable")
294296
}
295-
TextLayoutManager.deleteCachedSpannableForTag(id)
297+
spannableCacheOwner()?.let { TextLayoutManager.deleteCachedSpannableForTag(it, id) }
298+
}
299+
300+
// Same lookup as UIManagerHelper.getReactContext, but returns null instead of throwing
301+
// ClassCastException, so finalize() cannot throw on the finalizer thread.
302+
private fun spannableCacheOwner(): ReactContext? {
303+
val context = context
304+
return context as? ReactContext ?: (context as? ContextWrapper)?.baseContext as? ReactContext
296305
}
297306

298307
// After the text changes inside an EditText, TextView checks if a layout() has been requested.
@@ -1220,7 +1229,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
12201229
sb.length,
12211230
Spannable.SPAN_INCLUSIVE_INCLUSIVE,
12221231
)
1223-
TextLayoutManager.setCachedSpannableForTag(id, sb)
1232+
spannableCacheOwner()?.let { TextLayoutManager.setCachedSpannableForTag(it, id, sb) }
12241233
}
12251234

12261235
public fun setEventDispatcher(eventDispatcher: EventDispatcher?) {

0 commit comments

Comments
 (0)