From 5c87ca977a144ef6cd48da276383b7250c8998ff Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 25 Sep 2026 15:46:27 -0700 Subject: [PATCH] Guard RTL overhang handling for fixed-line text (#58685) Summary: Pull Request resolved: https://github.com/react/react-native/pull/58685 Add Android regression coverage for exactly constrained RTL text that reserves start-side ink overhang while enforcing a maximum line count and ellipsizing overflow. Also cover the mixed-direction exclusion, where a shared right-side reservation must not be applied. Changelog: [Internal] ___ Differential Revision: D121670747 fbshipit-source-id: 307461e4ef24d0dad90e609aa8dd7845efc70fa7 --- .../TextLayoutManagerStartOverhangTest.kt | 78 +++++++++++++++++-- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt index cde30f13863..2f23db9c304 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerStartOverhangTest.kt @@ -7,6 +7,7 @@ package com.facebook.react.views.text +import android.annotation.SuppressLint import android.graphics.RectF import android.text.BoringLayout import android.text.Layout @@ -24,6 +25,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@SuppressLint("NewApi") class TextLayoutManagerStartOverhangTest { @Test @@ -56,6 +58,64 @@ class TextLayoutManagerStartOverhangTest { assertThat(TextLayoutManager.getRtlRightOverhang(layout)).isEqualTo(8) } + @Test + @Config(sdk = [35]) + fun `RTL overhang reservation preserves max lines and ellipsis`() { + val initialLayout = mock() + whenever(initialLayout.lineCount).thenReturn(2) + whenever(initialLayout.width).thenReturn(LAYOUT_WIDTH.toInt()) + whenever(initialLayout.getParagraphDirection(any())).thenReturn(Layout.DIR_RIGHT_TO_LEFT) + whenever(initialLayout.computeDrawingBoundingBox()) + .thenReturn(RectF(10f, 0f, LAYOUT_WIDTH + 7.1f, 40f)) + var rebuiltWidth = 0 + + val layout = + TextLayoutManager.adjustLayoutForRtlRightOverhang( + initialLayout, + LAYOUT_WIDTH.toInt(), + ) { adjustedWidth -> + rebuiltWidth = adjustedWidth + createLayout( + YogaMeasureMode.EXACTLY, + text = + SpannableString( + listOf( + "\u200Ffirst paragraph", + "\u200Fsecond paragraph", + "\u200Fthird paragraph", + ) + .joinToString("\n"), + ), + layoutWidth = adjustedWidth.toFloat(), + ellipsizeMode = TextUtils.TruncateAt.END, + maxNumberOfLines = 2, + ) + } + + assertThat(rebuiltWidth).isEqualTo(192) + assertThat(layout.width).isEqualTo(rebuiltWidth) + assertThat(layout.lineCount).isEqualTo(2) + assertThat(layout.getEllipsisCount(layout.lineCount - 1)).isGreaterThan(0) + } + + @Test + @Config(sdk = [35]) + fun `mixed direction text does not reserve RTL right overhang`() { + val initialLayout = mock() + whenever(initialLayout.lineCount).thenReturn(2) + whenever(initialLayout.width).thenReturn(200) + whenever(initialLayout.getParagraphDirection(0)).thenReturn(Layout.DIR_RIGHT_TO_LEFT) + whenever(initialLayout.getParagraphDirection(1)).thenReturn(Layout.DIR_LEFT_TO_RIGHT) + whenever(initialLayout.computeDrawingBoundingBox()).thenReturn(RectF(10f, 0f, 208f, 40f)) + + val layout = + TextLayoutManager.adjustLayoutForRtlRightOverhang(initialLayout, 200) { + throw AssertionError("Mixed-direction text must not be rebuilt") + } + + assertThat(layout).isSameAs(initialLayout) + } + @Test @Config(sdk = [34]) fun `EXACTLY mode remains supported before Android 15`() { @@ -64,9 +124,15 @@ class TextLayoutManagerStartOverhangTest { assertThat(layout.width).isEqualTo(LAYOUT_WIDTH.toInt()) } - private fun createLayout(widthMode: YogaMeasureMode): Layout { - val text = SpannableString("\u0622\u064a\u0629 \u0627\u0644\u0643\u0631\u0633\u064a") - val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f } + private fun createLayout( + widthMode: YogaMeasureMode, + text: SpannableString = + SpannableString("\u0622\u064a\u0629 \u0627\u0644\u0643\u0631\u0633\u064a"), + layoutWidth: Float = LAYOUT_WIDTH, + ellipsizeMode: TextUtils.TruncateAt? = null, + maxNumberOfLines: Int = 2, + paint: TextPaint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f }, + ): Layout { val method = TextLayoutManager::class .java @@ -91,15 +157,15 @@ class TextLayoutManagerStartOverhangTest { TextLayoutManager, text, null, - LAYOUT_WIDTH, + layoutWidth, widthMode, /* includeFontPadding = */ false, /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, Layout.Alignment.ALIGN_NORMAL, /* justificationMode = */ 0, - /* ellipsizeMode = */ null, - /* maxNumberOfLines = */ 2, + /* ellipsizeMode = */ ellipsizeMode, + /* maxNumberOfLines = */ maxNumberOfLines, paint, ) as Layout }