diff --git a/patches/react-native/details.md b/patches/react-native/details.md index b302e573e59b..e4bafbfb91fb 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -340,3 +340,10 @@ - Upstream PR/issue: https://github.com/facebook/react-native/pull/55751 (introduced the fix behind the `fixFindShadowNodeByTagRaceCondition` flag) and https://github.com/facebook/react-native/pull/56850 (removed the flag in RN 0.87.0) - E/App issue: https://github.com/Expensify/App/issues/97471 - PR introducing patch: https://github.com/Expensify/App/pull/97496 + +### [react-native+0.85.3+041+fix-android15-text-clipping-boundswidth.patch](react-native+0.85.3+041+fix-android15-text-clipping-boundswidth.patch) + +- Reason: On Android 15+, `StaticLayout`/`TextView` can use glyph visual bounds (which are wider than advance-based width) when rendering, causing trailing characters or whole words to be clipped off (e.g. "You appear to be offline" rendering as "You appear to be"). RN 0.85.3's `fixTextClippingAndroid15useBoundsForWidth` feature flag was meant to fix this, and — unlike its `Defaults` value of `false` — the OSS Android "Experimental" flag profile (`ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android`/`ReactNativeFeatureFlagsOverridesOSSExperimental.h`) overrode it to `true`, so it was actually active. Its implementation had bugs: it computed each line's visual-bounds width as `getLineRight(i) - getLineLeft(i)` (wrong for lines with a nonzero left offset), applied `setUseBoundsForWidth(true)` to the *final* rendered `StaticLayout` and not just the measurement probe (the exact path upstream identified as causing multiline/wrapping regressions; flagged in PR review by chatgpt-codex-connector, https://github.com/Expensify/App/pull/97860#discussion_r3719363793), and never special-cased `YogaMeasureMode.EXACTLY`, so text with an already-determined exact width got resized to its desired width instead. Upstream abandoned that whole flag-gated implementation (facebook/react-native#56282, "Delete fixTextClippingAndroid15useBoundsForWidth()") rather than patch it in place, then proposed a from-scratch replacement (react/react-native#57117) with a `getDesiredWidth()` probe helper (using reflection for `StaticLayout.Builder.setUseBoundsForWidth` since the method is API 35+), `getLineMax(i)` for per-line width, a floor at the advance-based width, and an explicit `EXACTLY -> ceil(width).toInt()` short-circuit that skips the probe. This patch follows the same path: it removes the flag-gated implementation and the OSS-Experimental override entirely (mirroring an earlier internal attempt at this, Expensify/App#95922, which is unmerged/closed), then re-implements the fix unconditionally (SDK-gated only, no flag) matching PR 57117 as closely as possible — including porting `AndroidVersion.VERSION_CODE_VANILLA_ICE_CREAM` and the reflection-based `setUseBoundsForWidthMethod` — so this patch stays easy to diff against and eventually drop once 57117 (or an equivalent) lands upstream and we upgrade past it. +- Upstream PR/issue: https://github.com/facebook/react-native/pull/54721 (original flagged implementation, since abandoned), https://github.com/react/react-native/pull/56282 (deleted it), https://github.com/react/react-native/pull/57117 (unmerged replacement this patch ports) +- E/App issue: https://github.com/Expensify/App/issues/76531 +- PR introducing patch: https://github.com/Expensify/App/pull/97860 diff --git a/patches/react-native/react-native+0.85.3+041+fix-android15-text-clipping-boundswidth.patch b/patches/react-native/react-native+0.85.3+041+fix-android15-text-clipping-boundswidth.patch new file mode 100644 index 000000000000..773eeb5bd69e --- /dev/null +++ b/patches/react-native/react-native+0.85.3+041+fix-android15-text-clipping-boundswidth.patch @@ -0,0 +1,235 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt +index d740c6f..2fffcd0 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt +@@ -29,8 +29,6 @@ public open class ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android : + + override fun enableSwiftUIBasedFilters(): Boolean = true + +- override fun fixTextClippingAndroid15useBoundsForWidth(): Boolean = true +- + override fun preventShadowTreeCommitExhaustion(): Boolean = true + + override fun useSharedAnimatedBackend(): Boolean = true +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt +index 325ab60..05c9221 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/util/AndroidVersion.kt +@@ -13,6 +13,13 @@ import android.os.Build + /** Helper class for checking Android version-related information. */ + internal object AndroidVersion { + ++ /** ++ * This is the version code for Android 15 (SDK Level 35). Internally at Meta this code is also ++ * compiled against SDK 34, so we need to retain this constant instead of using ++ * [Build.VERSION_CODES.VANILLA_ICE_CREAM] directly. ++ */ ++ internal const val VERSION_CODE_VANILLA_ICE_CREAM: Int = 35 ++ + /** + * This is the version code for Android 16 (SDK Level 36). Delete it once we bump up the default + * compile SDK version to 36. +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +index dca0ecb..786b75a 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +@@ -40,6 +40,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.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 + import com.facebook.react.views.text.internal.span.CustomStyleSpan +@@ -170,6 +171,18 @@ internal object TextLayoutManager { + + private val tagToSpannableCache = ConcurrentHashMap() + ++ // Lazily cached Method for StaticLayout.Builder.setUseBoundsForWidth (API 35+). ++ // Reflection is needed because some internal targets compile against an SDK older than 35. ++ private val setUseBoundsForWidthMethod: java.lang.reflect.Method? by lazy { ++ try { ++ StaticLayout.Builder::class ++ .java ++ .getMethod("setUseBoundsForWidth", Boolean::class.javaPrimitiveType) ++ } catch (_: ReflectiveOperationException) { ++ null ++ } ++ } ++ + fun setCachedSpannableForTag(reactTag: Int, sp: Spannable): Unit { + tagToSpannableCache[reactTag] = sp + } +@@ -827,36 +840,69 @@ internal object TextLayoutManager { + ) + } + +- // Pre-Android 15: Use existing advance-based logic ++ val layoutWidth = ++ when (widthYogaMeasureMode) { ++ YogaMeasureMode.EXACTLY -> ceil(width).toInt() ++ YogaMeasureMode.AT_MOST -> ++ min( ++ getDesiredWidth( ++ text, ++ includeFontPadding, ++ textBreakStrategy, ++ hyphenationFrequency, ++ alignment, ++ justificationMode, ++ paint, ++ ), ++ floor(width).toInt(), ++ ) ++ else -> ++ getDesiredWidth( ++ text, ++ includeFontPadding, ++ textBreakStrategy, ++ hyphenationFrequency, ++ alignment, ++ justificationMode, ++ paint, ++ ) ++ } ++ return buildLayout( ++ text, ++ layoutWidth, ++ includeFontPadding, ++ textBreakStrategy, ++ hyphenationFrequency, ++ alignment, ++ justificationMode, ++ ellipsizeMode, ++ maxNumberOfLines, ++ paint, ++ ) ++ } ++ ++ // Android 15+ can clip text whose visual glyph bounds are wider than its advance-based desired ++ // width. Probe the visual bounds width (when the setUseBoundsForWidth API is available) and use ++ // it only to grow the desired width — never to shrink below the plain advance-based width. ++ private fun getDesiredWidth( ++ text: Spannable, ++ includeFontPadding: Boolean, ++ textBreakStrategy: Int, ++ hyphenationFrequency: Int, ++ alignment: Layout.Alignment, ++ justificationMode: Int, ++ paint: TextPaint, ++ ): Int { ++ val advanceWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() ++ + if ( +- Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM || +- !ReactNativeFeatureFlags.fixTextClippingAndroid15useBoundsForWidth() ++ Build.VERSION.SDK_INT < VERSION_CODE_VANILLA_ICE_CREAM || ++ setUseBoundsForWidthMethod == null + ) { +- val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt() +- +- val layoutWidth = +- when (widthYogaMeasureMode) { +- YogaMeasureMode.EXACTLY -> floor(width).toInt() +- YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt()) +- else -> desiredWidth +- } +- return buildLayout( +- text, +- layoutWidth, +- includeFontPadding, +- textBreakStrategy, +- hyphenationFrequency, +- alignment, +- justificationMode, +- ellipsizeMode, +- maxNumberOfLines, +- paint, +- ) ++ return advanceWidth + } + +- // Android 15+: Need to account for visual bounds +- // Step 1: Create unconstrained layout to get visual bounds width +- val unconstrainedLayout = ++ val visualBoundsLayout = + buildLayout( + text, + Int.MAX_VALUE / 2, +@@ -868,34 +914,15 @@ internal object TextLayoutManager { + null, + ReactConstants.UNSET, + paint, ++ useBoundsForWidth = true, + ) + +- // Calculate visual bounds width from unconstrained layout +- var desiredVisualWidth = 0f +- for (i in 0 until unconstrainedLayout.lineCount) { +- val lineWidth = unconstrainedLayout.getLineRight(i) - unconstrainedLayout.getLineLeft(i) +- desiredVisualWidth = max(desiredVisualWidth, lineWidth) ++ var visualBoundsWidth = 0f ++ for (i in 0 until visualBoundsLayout.lineCount) { ++ visualBoundsWidth = max(visualBoundsWidth, visualBoundsLayout.getLineMax(i)) + } + +- val layoutWidth = +- when (widthYogaMeasureMode) { +- YogaMeasureMode.AT_MOST -> min(ceil(desiredVisualWidth).toInt(), floor(width).toInt()) +- else -> ceil(desiredVisualWidth).toInt() +- } +- +- // Step 2: Create final layout with correct width +- return buildLayout( +- text, +- layoutWidth, +- includeFontPadding, +- textBreakStrategy, +- hyphenationFrequency, +- alignment, +- justificationMode, +- ellipsizeMode, +- maxNumberOfLines, +- paint, +- ) ++ return max(advanceWidth, ceil(visualBoundsWidth).toInt()) + } + + private fun buildLayout( +@@ -909,6 +936,7 @@ internal object TextLayoutManager { + ellipsizeMode: TextUtils.TruncateAt?, + maxNumberOfLines: Int, + paint: TextPaint, ++ useBoundsForWidth: Boolean = false, + ): Layout { + val builder = + StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth) +@@ -930,11 +958,8 @@ internal object TextLayoutManager { + builder.setUseLineSpacingFromFallbacks(true) + } + +- if ( +- Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && +- ReactNativeFeatureFlags.fixTextClippingAndroid15useBoundsForWidth() +- ) { +- builder.setUseBoundsForWidth(true) ++ if (useBoundsForWidth) { ++ setUseBoundsForWidthMethod?.invoke(builder, true) + } + + return builder.build() +diff --git a/node_modules/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h b/node_modules/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h +index b97ed6d..d5045cc 100644 +--- a/node_modules/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h ++++ b/node_modules/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h +@@ -39,10 +39,6 @@ class ReactNativeFeatureFlagsOverridesOSSExperimental : public ReactNativeFeatur + return true; + } + +- bool fixTextClippingAndroid15useBoundsForWidth() override { +- return true; +- } +- + bool preventShadowTreeCommitExhaustion() override { + return true; + }