Skip to content
Merged
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
11 changes: 11 additions & 0 deletions patches/react-native/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,14 @@
- Upstream PR/issue: [facebook/react-native#57425](https://github.com/facebook/react-native/pull/57425) / [d2ac1904118](https://github.com/facebook/react-native/commit/d2ac190411877e7a1bc94ffac346c5fd35b65a7c)
- E/App issue: N/A
- PR introducing patch: https://github.com/Expensify/Mobile-Expensify/pull/14058

### [react-native+0.86.0+040+fix-android15-text-clipping.patch](react-native+0.86.0+040+fix-android15-text-clipping.patch)

- Reason: Backports upstream's fix for Android 15+ text clipping. `TextLayoutManager` sized text layouts from `ceil(Layout.getDesiredWidth(...))`, which sums glyph *advances* only; on Android 15+ a line's visual glyph bounds can exceed that, so the layout ends up a fraction of a pixel too narrow. Line breaking is a step function, so that shortfall moves a whole trailing word onto a second line inside a view whose height was computed for one line, and that line is clipped: the word disappears with no ellipsis and no change in row height. The patch adds a `getDesiredWidth` helper that, on API 35+, builds a probe `StaticLayout` with `setUseBoundsForWidth(true)` and returns `max(advanceWidth, ceil(visualBoundsWidth))`, so the desired width can never shrink below the previous value. Only the `AT_MOST` and `UNDEFINED` measure modes are affected; `EXACTLY` still uses the width Yoga supplies, and the final layout keeps the existing advance-based bounds mode — which is what got the earlier blanket `setUseBoundsForWidth` attempt reverted in [5964a197](https://github.com/facebook/react-native/commit/5964a197) after multiline and wrapping regressions.
- Why this rather than reverting `LINEAR_TEXT_FLAG`: RN 0.86 made `Paint.LINEAR_TEXT_FLAG` unconditional ([#56409](https://github.com/facebook/react-native/pull/56409)) — the `enableAndroidLinearText` flag it deleted defaulted to `false` in OSS, so 0.85.3 never set it. Disabling hinting shifts glyph advance widths by a fraction of a pixel, widening the advance-vs-visual-bounds gap and pushing borderline strings over the wrapping threshold; that is why the bug presents as a 0.85.3 → 0.86.0 regression. Reverting the flag was verified to fix the reported cases too, but it only removes the amplifier — the advance-based measurement stays wrong and borderline strings can still wrap. This patch fixes the measurement itself, keeps 0.86's intended text rendering, and is not a deviation from upstream.
- Upstream PR/issue: [facebook/react-native#57117](https://github.com/facebook/react-native/pull/57117) (fixes [#56402](https://github.com/facebook/react-native/issues/56402))
- E/App issue: [#98499](https://github.com/Expensify/App/issues/98499) [#98690](https://github.com/Expensify/App/issues/98690)
- PR introducing patch: https://github.com/Expensify/App/pull/99069
- Reproduction: Android 16, custom font, non-default font scale *and* non-default display density — e.g. `wm size 1080x2340`, `wm density 420` (physical 450), `settings put system font_scale 0.8`, matching the Galaxy S25 FE the bug was reported on. The layout must be re-mounted (navigate forward, then back) before the clipping appears. Devices at their physical density are far less likely to hit it: at 450 dpi a 1080 px screen is exactly 384 dp, while the 420 dpi override makes it 411.428571… dp, so every derived width carries a fractional residue for `ceil`/`floor` to disagree on.
- Upstream history: this is upstream's *third* attempt at the same bug. [#54721](https://github.com/facebook/react-native/pull/54721) (commit [8347cc4b5](https://github.com/facebook/react-native/commit/8347cc4b50ca9229b638d0823d3148fed50b9a61)) closed [#53286](https://github.com/facebook/react-native/issues/53286) in December 2025 by calling `setUseBoundsForWidth(true)` on the final `StaticLayout`, but it was gated behind `fixTextClippingAndroid15useBoundsForWidth`, which **defaulted to `false` in OSS** — so 0.85.3 never ran it. RN 0.86.0 removed the flag and both call sites after multiline and wrapping regressions, leaving only the now-unused `setUseBoundsForWidthMethod` reflection handle in `TextLayoutManager` (which this patch reuses). The bug was refiled as [#56402](https://github.com/facebook/react-native/issues/56402) and is still open. #57117 is the narrower retry: the visual-bounds layout is only a measurement probe, so the final layout keeps advance-based bounds. **Because the two earlier attempts were reverted for wrapping regressions specifically, wrapping is the thing to regression-test on Android 15+.**
- 0.86.0 migration note: **upstream #57117 is still open and has not been reviewed by Meta.** Re-evaluate on the RN 0.87 upgrade and drop this patch once the fix lands upstream. Two things to weigh before shipping: the probe layout costs one extra `StaticLayout` build per text measure on API 35+ for `AT_MOST`/`UNDEFINED`, on a hot path; and widening measured text can change wrapping app-wide on Android 15+. The upstream diff's `TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt` hunks are omitted because the npm package does not ship `ReactAndroid/src/test`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 b85d294..bf9db18 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
@@ -41,6 +41,7 @@
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
@@ -842,14 +843,33 @@
includeFontPadding,
)
}
-
- val desiredWidth = ceil(Layout.getDesiredWidth(text, paint)).toInt()

val layoutWidth =
when (widthYogaMeasureMode) {
YogaMeasureMode.EXACTLY -> ceil(width).toInt()
- YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt())
- else -> desiredWidth
+ 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,
@@ -863,6 +883,47 @@
maxNumberOfLines,
paint,
)
+ }
+
+ 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 < VERSION_CODE_VANILLA_ICE_CREAM ||
+ setUseBoundsForWidthMethod == null
+ ) {
+ return advanceWidth
+ }
+
+ val visualBoundsLayout =
+ buildLayout(
+ text,
+ Int.MAX_VALUE / 2,
+ includeFontPadding,
+ textBreakStrategy,
+ hyphenationFrequency,
+ alignment,
+ justificationMode,
+ null,
+ ReactConstants.UNSET,
+ paint,
+ useBoundsForWidth = true,
+ )
+
+ var visualBoundsWidth = 0f
+ for (i in 0 until visualBoundsLayout.lineCount) {
+ visualBoundsWidth = max(visualBoundsWidth, visualBoundsLayout.getLineMax(i))
+ }
+
+ return max(advanceWidth, ceil(visualBoundsWidth).toInt())
}

private fun buildLayout(
@@ -876,6 +937,7 @@
ellipsizeMode: TextUtils.TruncateAt?,
maxNumberOfLines: Int,
paint: TextPaint,
+ useBoundsForWidth: Boolean = false,
): Layout {
val builder =
StaticLayout.Builder.obtain(text, 0, text.length, paint, layoutWidth)
@@ -895,6 +957,10 @@

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setUseLineSpacingFromFallbacks(true)
+ }
+
+ if (useBoundsForWidth) {
+ setUseBoundsForWidthMethod?.invoke(builder, true)
}

return builder.build()
Loading