diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 3fe95a4595db..400797fe6762 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -259,6 +259,7 @@ public FabricUIManager( mViewManagerRegistry = viewManagerRegistry; mReactApplicationContext.registerComponentCallbacks(viewManagerRegistry); + TextLayoutManager.createSpannableCache(mReactApplicationContext); } @Override @@ -496,6 +497,7 @@ public void invalidate() { } mBinding = null; + TextLayoutManager.destroySpannableCache(mReactApplicationContext); ViewManagerPropertyUpdater.clear(); } @@ -658,7 +660,8 @@ public long measureText( ? (ReactTextViewManagerCallback) textViewManager : null, attachmentsPositions, - mTextEffectRegistry); + mTextEffectRegistry, + mReactApplicationContext); } @AnyThread diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index d9c28ca5fa20..e909cdb9ab7d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -30,6 +30,7 @@ import com.facebook.common.logging.FLog import com.facebook.infer.annotation.Assertions import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray import com.facebook.react.common.ReactConstants @@ -42,6 +43,7 @@ import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.ReactAccessibilityDelegate +import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.util.AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM import com.facebook.react.views.text.internal.span.CustomLetterSpacingSpan import com.facebook.react.views.text.internal.span.CustomLineHeightSpan @@ -115,7 +117,11 @@ internal object TextLayoutManager { private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line" - private val tagToSpannableCache = ConcurrentHashMap() + // TextInput spannables keyed by react tag, one map per React instance. Tags restart from the same + // value in every instance, so entries must not be shared across instances. FabricUIManager + // creates and destroys the map for its ReactApplicationContext. + private val tagToSpannableCaches = + ConcurrentHashMap>() // These wrappers mirror Android 15 APIs but use reflection because some internal targets still // compile against Android 14. They return null when the API is unavailable or cannot be invoked. @@ -180,12 +186,35 @@ internal object TextLayoutManager { null } - fun setCachedSpannableForTag(reactTag: Int, sp: Spannable) { - tagToSpannableCache[reactTag] = sp + // Views hold a ThemedReactContext while FabricUIManager holds the ReactApplicationContext it + // wraps. Every cache access goes through this so both resolve to the same key. + private fun spannableCacheKey(reactContext: ReactContext): ReactContext = + if (reactContext is ThemedReactContext) reactContext.reactApplicationContext else reactContext + + @JvmStatic + fun createSpannableCache(reactContext: ReactContext) { + tagToSpannableCaches[spannableCacheKey(reactContext)] = ConcurrentHashMap() + } + + @JvmStatic + fun destroySpannableCache(reactContext: ReactContext) { + tagToSpannableCaches.remove(spannableCacheKey(reactContext)) + } + + private fun getSpannableCache(reactContext: ReactContext): ConcurrentHashMap? = + tagToSpannableCaches[spannableCacheKey(reactContext)] + + // Returns null once FabricUIManager.invalidate() has removed the instance's map, or once + // ReactEditText.finalize() has evicted the tag while a background layout still measures it. + internal fun getCachedSpannable(reactContext: ReactContext, reactTag: Int): Spannable? = + getSpannableCache(reactContext)?.get(reactTag) + + fun setCachedSpannableForTag(reactContext: ReactContext, reactTag: Int, sp: Spannable) { + getSpannableCache(reactContext)?.put(reactTag, sp) } - fun deleteCachedSpannableForTag(reactTag: Int) { - tagToSpannableCache.remove(reactTag) + fun deleteCachedSpannableForTag(reactContext: ReactContext, reactTag: Int) { + getSpannableCache(reactContext)?.remove(reactTag) } fun isRTL(attributedString: MapBuffer): Boolean { @@ -765,25 +794,15 @@ internal object TextLayoutManager { attributedString: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry?, - ): Spannable { - val text: Spannable? - if (attributedString.contains(AS_KEY_CACHE_ID)) { - val cacheId = attributedString.getInt(AS_KEY_CACHE_ID) - text = checkNotNull(tagToSpannableCache[cacheId]) - } else { - text = - createSpannableFromAttributedString( - assets, - fontWeightAdjustment, - attributedString.getMapBuffer(AS_KEY_FRAGMENTS), - reactTextViewManagerCallback, - null, - textEffectRegistry, - ) - } - - return text - } + ): Spannable = + createSpannableFromAttributedString( + assets, + fontWeightAdjustment, + attributedString.getMapBuffer(AS_KEY_FRAGMENTS), + reactTextViewManagerCallback, + null, + textEffectRegistry, + ) @OptIn(UnstableReactNativeAPI::class) private fun createSpannableFromAttributedString( @@ -1091,20 +1110,28 @@ internal object TextLayoutManager { heightYogaMeasureMode: YogaMeasureMode, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, - ): Layout { - val text = - getOrCreateSpannableForText( - assets, - fontWeightAdjustment, - attributedString, - reactTextViewManagerCallback, - textEffectRegistry, - ) - + spannableCacheOwner: ReactContext? = null, + ): Layout? { + val text: Spannable val paint: TextPaint if (attributedString.contains(AS_KEY_CACHE_ID)) { + // A layout that started before FabricUIManager.invalidate() or ReactEditText.finalize() can + // still measure here after the cached spannable is gone. There is nothing to measure, and the + // result is discarded. Without an owner there is no cache to read either. + text = + spannableCacheOwner?.let { + getCachedSpannable(it, attributedString.getInt(AS_KEY_CACHE_ID)) + } ?: return null paint = text.getSpans(0, 0, ReactTextPaintHolderSpan::class.java)[0].textPaint } else { + text = + getOrCreateSpannableForText( + assets, + fontWeightAdjustment, + attributedString, + reactTextViewManagerCallback, + textEffectRegistry, + ) val baseTextAttributes = TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) paint = scratchPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment) @@ -1463,6 +1490,7 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, attachmentsPositions: FloatArray?, textEffectRegistry: TextEffectRegistry? = null, + spannableCacheOwner: ReactContext? = null, ): Long = measureText( assets, @@ -1476,6 +1504,7 @@ internal object TextLayoutManager { reactTextViewManagerCallback, attachmentsPositions, textEffectRegistry, + spannableCacheOwner, ) @JvmStatic @@ -1492,6 +1521,7 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, attachmentsPositions: FloatArray?, textEffectRegistry: TextEffectRegistry? = null, + spannableCacheOwner: ReactContext? = null, ): Long { // TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic) val layout = @@ -1506,7 +1536,8 @@ internal object TextLayoutManager { heightYogaMeasureMode, reactTextViewManagerCallback, textEffectRegistry, - ) + spannableCacheOwner, + ) ?: return YogaMeasureOutput.make(0f, 0f) val maximumNumberOfLines = if (paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES)) @@ -1800,17 +1831,19 @@ internal object TextLayoutManager { textEffectRegistry: TextEffectRegistry? = null, ): WritableArray { val layout = - createLayoutForMeasurement( - assetManager, - fontWeightAdjustment, - attributedString, - paragraphAttributes, - width, - YogaMeasureMode.EXACTLY, - height, - YogaMeasureMode.EXACTLY, - reactTextViewManagerCallback, - textEffectRegistry, + checkNotNull( + createLayoutForMeasurement( + assetManager, + fontWeightAdjustment, + attributedString, + paragraphAttributes, + width, + YogaMeasureMode.EXACTLY, + height, + YogaMeasureMode.EXACTLY, + reactTextViewManagerCallback, + textEffectRegistry, + ) ) return FontMetricsUtil.getFontMetrics( layout.text, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index 754f10026624..42101b039fc2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -9,6 +9,7 @@ package com.facebook.react.views.textinput import android.annotation.SuppressLint import android.content.Context +import android.content.ContextWrapper import android.content.res.Configuration import android.graphics.Canvas import android.graphics.Color @@ -46,6 +47,7 @@ import androidx.core.graphics.withTranslation import androidx.core.util.Predicate import androidx.core.view.ViewCompat import com.facebook.common.logging.FLog +import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReactSoftExceptionLogger.logSoftException import com.facebook.react.common.ReactConstants import com.facebook.react.common.assets.ReactFontManager @@ -292,7 +294,14 @@ public open class ReactEditText public constructor(context: Context) : AppCompat if (DEBUG_MODE) { FLog.e(TAG, "finalize[$id] delete cached spannable") } - TextLayoutManager.deleteCachedSpannableForTag(id) + spannableCacheOwner()?.let { TextLayoutManager.deleteCachedSpannableForTag(it, id) } + } + + // Same lookup as UIManagerHelper.getReactContext, but returns null instead of throwing + // ClassCastException, so finalize() cannot throw on the finalizer thread. + private fun spannableCacheOwner(): ReactContext? { + val context = context + return context as? ReactContext ?: (context as? ContextWrapper)?.baseContext as? ReactContext } // 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 sb.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE, ) - TextLayoutManager.setCachedSpannableForTag(id, sb) + spannableCacheOwner()?.let { TextLayoutManager.setCachedSpannableForTag(it, id, sb) } } public fun setEventDispatcher(eventDispatcher: EventDispatcher?) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextSpannableCacheTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextSpannableCacheTest.kt new file mode 100644 index 000000000000..b1f6c26a336e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextSpannableCacheTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.textinput + +import android.text.SpannableString +import android.util.DisplayMetrics +import android.view.Gravity +import androidx.core.content.res.ResourcesCompat.ID_NULL +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.common.mapbuffer.WritableMapBuffer +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.text.ReactTextUpdate +import com.facebook.react.views.text.TextLayoutManager +import com.facebook.yoga.YogaMeasureMode +import com.facebook.yoga.YogaMeasureOutput +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ReactEditTextSpannableCacheTest { + + private lateinit var manager: ReactTextInputManager + private val oldRuntime = mock() + private val newRuntime = mock() + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + manager = ReactTextInputManager() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + } + + @After + fun tearDown() { + TextLayoutManager.destroySpannableCache(oldRuntime) + TextLayoutManager.destroySpannableCache(newRuntime) + } + + @Test + fun `a view from a destroyed runtime does not evict a new runtime's spannable for the same tag`() { + // Mirrors FabricUIManager's constructor and invalidate() across a React instance reload. + TextLayoutManager.createSpannableCache(oldRuntime) + val staleView = createViewWithCachedText(oldRuntime, "stale") + TextLayoutManager.destroySpannableCache(oldRuntime) + TextLayoutManager.createSpannableCache(newRuntime) + + assertThatThrownBy { cachedSpannable(newRuntime) } + .isInstanceOf(IllegalStateException::class.java) + + val liveView = createViewWithCachedText(newRuntime, "live") + runFinalizer(staleView) + + assertThat(YogaMeasureOutput.getHeight(measureCachedText(newRuntime))).isPositive() + assertThat(cachedSpannable(newRuntime).toString()).isEqualTo("live") + + runFinalizer(liveView) + + assertThatThrownBy { cachedSpannable(newRuntime) } + .isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `a view's spannable lands in the cache registered for its ReactApplicationContext`() { + TextLayoutManager.createSpannableCache(newRuntime) + val view = createViewWithCachedText(newRuntime, "live") + + assertThat(view.context).isInstanceOf(ThemedReactContext::class.java) + assertThat(cachedSpannable(newRuntime).toString()).isEqualTo("live") + } + + @Test + fun `measureText through the cache id measures the instance's cached spannable`() { + TextLayoutManager.createSpannableCache(newRuntime) + createViewWithCachedText(newRuntime, "live") + + assertThat(YogaMeasureOutput.getHeight(measureCachedText(newRuntime))).isPositive() + } + + @Test + fun `measureText through the cache id after the runtime's cache is destroyed measures zero`() { + TextLayoutManager.createSpannableCache(oldRuntime) + createViewWithCachedText(oldRuntime, "stale") + TextLayoutManager.destroySpannableCache(oldRuntime) + + val measurement = measureCachedText(oldRuntime) + + assertThat(YogaMeasureOutput.getWidth(measurement)).isZero() + assertThat(YogaMeasureOutput.getHeight(measurement)).isZero() + } + + @Test + fun `measureText through the cache id after the view's finalizer evicts it measures zero`() { + TextLayoutManager.createSpannableCache(newRuntime) + runFinalizer(createViewWithCachedText(newRuntime, "evicted")) + + val measurement = measureCachedText(newRuntime) + + assertThat(YogaMeasureOutput.getWidth(measurement)).isZero() + assertThat(YogaMeasureOutput.getHeight(measurement)).isZero() + } + + private fun createViewWithCachedText( + runtime: ReactApplicationContext, + text: String, + ): ReactEditText { + val themedContext = + ThemedReactContext(runtime, RuntimeEnvironment.getApplication(), null, ID_NULL) + val view = manager.createViewInstance(themedContext) + view.id = TAG + view.stateWrapper = mock() + view.maybeSetTextFromState( + ReactTextUpdate( + SpannableString(text), + 0, + view.gravity and Gravity.HORIZONTAL_GRAVITY_MASK, + 0, + 0, + ), + ) + return view + } + + private fun runFinalizer(view: ReactEditText) { + ReactEditText::class.java.getDeclaredMethod("finalize").apply { isAccessible = true }(view) + } + + private fun cachedSpannable(runtime: ReactApplicationContext) = + checkNotNull(TextLayoutManager.getCachedSpannable(runtime, TAG)) + + @OptIn(UnstableReactNativeAPI::class) + private fun measureCachedText(runtime: ReactApplicationContext): Long = + TextLayoutManager.measureText( + RuntimeEnvironment.getApplication().assets, + WritableMapBuffer().put(TextLayoutManager.AS_KEY_CACHE_ID, TAG), + WritableMapBuffer() + .put(TextLayoutManager.PA_KEY_TEXT_BREAK_STRATEGY, "simple") + .put(TextLayoutManager.PA_KEY_HYPHENATION_FREQUENCY, "none"), + 100f, + YogaMeasureMode.EXACTLY, + 100f, + YogaMeasureMode.EXACTLY, + null, + null, + null, + runtime, + ) + + private companion object { + const val TAG = 42 + } +}