Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ public FabricUIManager(

mViewManagerRegistry = viewManagerRegistry;
mReactApplicationContext.registerComponentCallbacks(viewManagerRegistry);
TextLayoutManager.createSpannableCache(mReactApplicationContext);
}

@Override
Expand Down Expand Up @@ -496,6 +497,7 @@ public void invalidate() {
}
mBinding = null;

TextLayoutManager.destroySpannableCache(mReactApplicationContext);
ViewManagerPropertyUpdater.clear();
}

Expand Down Expand Up @@ -658,7 +660,8 @@ public long measureText(
? (ReactTextViewManagerCallback) textViewManager
: null,
attachmentsPositions,
mTextEffectRegistry);
mTextEffectRegistry,
mReactApplicationContext);
}

@AnyThread
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -115,7 +117,11 @@ internal object TextLayoutManager {

private const val TEXT_WIDTH_MODE_LONGEST_LINE = "longest-line"

private val tagToSpannableCache = ConcurrentHashMap<Int, Spannable>()
// 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<ReactContext, ConcurrentHashMap<Int, Spannable>>()

// 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.
Expand Down Expand Up @@ -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<Int, Spannable>? =
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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1463,6 +1490,7 @@ internal object TextLayoutManager {
reactTextViewManagerCallback: ReactTextViewManagerCallback?,
attachmentsPositions: FloatArray?,
textEffectRegistry: TextEffectRegistry? = null,
spannableCacheOwner: ReactContext? = null,
): Long =
measureText(
assets,
Expand All @@ -1476,6 +1504,7 @@ internal object TextLayoutManager {
reactTextViewManagerCallback,
attachmentsPositions,
textEffectRegistry,
spannableCacheOwner,
)

@JvmStatic
Expand All @@ -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 =
Expand All @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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?) {
Expand Down
Loading
Loading