Skip to content
Closed
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
7 changes: 7 additions & 0 deletions patches/react-native/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@linhvovan29546 Do we have any upstream PR to implement this batch into the original repo?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but I didn't author it. react/react-native#57117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we hold this until there are updates in the upstream PR?

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<Int, Spannable>()

+ // 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;
}
Loading