From e233142cfa63d256414fe423e40bbd54972ee10b Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Tue, 13 Oct 2026 13:17:39 -0700 Subject: [PATCH 1/2] fix(android): clamp text decoration offsets to truncated layout (#58366) Summary: On Android, a `Text` with `numberOfLines` can crash when a nested `Text` has `textDecorationLine: 'underline'` or `'line-through'` and the decorated span starts or ends past what the truncated layout actually rendered. `drawSpannedDecoration()` reads `start`/`end` from the full, untruncated `Spanned` text and passes them straight to `Layout.getPrimaryHorizontal()`, which throws `IndexOutOfBoundsException` once numberOfLines cuts the layout shorter than the span. This clamps both offsets to the last position the truncated (and possibly tail-ellipsized) layout can actually resolve before using them, so a partially hidden span draws its decoration up to the visible boundary and a fully hidden span draws nothing, instead of crashing. ## Changelog: [ANDROID] [FIXED] - Fix crash drawing text decoration (underline/strikethrough) when a nested Text's span extends past a numberOfLines-truncated layout Pull Request resolved: https://github.com/react/react-native/pull/58366 Test Plan: Added two regression tests in `TextDecorationStyleTest`: both build a single-line, tail-ellipsized `StaticLayout`. One checks clipping a span that starts visible and extends beyond the layout; the other checks the zero-length line for a fully hidden span. The formatting/API-annotation correction leaves the production source, test bodies, and assertions unchanged. Exact parent test run: https://www.internalfb.com/intern/testinfra/testrun/20829148333669211. Fixes https://github.com/react/react-native/issues/58356 Differential Revision: D125060226 Pulled By: Abbondanzo --- .../react/views/text/TextDecorationStyle.kt | 34 +++++++- .../views/text/TextDecorationStyleTest.kt | 85 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt index 65294940ee5c..95b4ee4e8e5b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt @@ -111,6 +111,26 @@ private fun drawDecorationLine( } } +/** + * The last offset [layout] can resolve a horizontal position for. Truncation (via `numberOfLines`) + * can leave the last line shorter than [Layout.getLineEnd] reports, since [layout] keeps the full + * untruncated text and only clips how much of it is laid out. When the last line ends in a tail + * ellipsis, anything from the ellipsis onward is unresolvable too; a leading/middle ellipsis + * doesn't shorten what's resolvable on that line, so it's left to [Layout.getLineEnd]. + */ +private fun visibleTextEnd(layout: Layout): Int { + val lastLine = layout.lineCount - 1 + val lineStart = layout.getLineStart(lastLine) + val lineEnd = layout.getLineEnd(lastLine) + val ellipsisStart = layout.getEllipsisStart(lastLine) + val ellipsisCount = layout.getEllipsisCount(lastLine) + return if (ellipsisCount > 0 && ellipsisStart + ellipsisCount == lineEnd - lineStart) { + lineStart + ellipsisStart + } else { + lineEnd + } +} + /** * Shared decoration drawing entry point used by [ReactUnderlineSpan] and [ReactStrikethroughSpan]. * Computes a density-aware stroke thickness, sets up a dedicated paint (to avoid mutating the @@ -174,13 +194,19 @@ internal fun drawSpannedDecoration( } } - val startLine = layout.getLineForOffset(start) - val endLine = layout.getLineForOffset(end) + val visibleEnd = visibleTextEnd(layout) + val clampedStart = min(start, visibleEnd) + val clampedEnd = min(end, visibleEnd) + + val startLine = layout.getLineForOffset(clampedStart) + val endLine = layout.getLineForOffset(clampedEnd) for (line in startLine..endLine) { val baseline = layout.getLineBaseline(line).toFloat() val rawX1 = - if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line) - val rawX2 = if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line) + if (line == startLine) layout.getPrimaryHorizontal(clampedStart) + else layout.getLineLeft(line) + val rawX2 = + if (line == endLine) layout.getPrimaryHorizontal(clampedEnd) else layout.getLineRight(line) // Normalize for RTL text where start may be to the right of end. val x1 = min(rawX1, rawX2) val x2 = max(rawX1, rawX2) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt index 10d95f696f1b..f0cf97cfc7f0 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt @@ -7,9 +7,24 @@ package com.facebook.react.views.text +import android.graphics.Canvas +import android.graphics.Color +import android.os.Build +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import androidx.annotation.RequiresApi import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +@RequiresApi(Build.VERSION_CODES.M) +@RunWith(RobolectricTestRunner::class) class TextDecorationStyleTest { @Test fun fromStringSolid() { @@ -50,4 +65,74 @@ class TextDecorationStyleTest { fun fromStringEmptyDefaultsToSolid() { assertThat(TextDecorationStyle.fromString("")).isEqualTo(TextDecorationStyle.SOLID) } + + @Test + fun drawSpannedDecorationClampsSpanPastTailEllipsis() { + val layout = buildTailEllipsizedLayout() + val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) + val baseline = layout.getLineBaseline(0).toFloat() + val x1 = layout.getPrimaryHorizontal(0) + val x2 = layout.getPrimaryHorizontal(visibleEnd) + val canvas = mock() + + // The span covers the whole string, well past what survived the ellipsis; pre-fix this + // called layout.getPrimaryHorizontal(TAIL_TEXT.length) and crashed with + // IndexOutOfBoundsException, since the ellipsized layout only resolves up to visibleEnd. + drawSpannedDecoration( + 0, + TAIL_TEXT.length, + canvas, + layout, + Color.BLACK, + TextDecorationStyle.SOLID, + ) { _, lineBaseline, thickness -> + lineBaseline + thickness + 1f + } + + verify(canvas).drawLine(eq(x1), eq(baseline + 1f), eq(x2), eq(baseline + 1f), any()) + } + + @Test + fun drawSpannedDecorationSkipsSpanEntirelyPastTailEllipsis() { + val layout = buildTailEllipsizedLayout() + val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) + val baseline = layout.getLineBaseline(0).toFloat() + val x = layout.getPrimaryHorizontal(visibleEnd) + val canvas = mock() + + // The whole span (e.g. a nested Text) starts after the ellipsis, fully hidden: it must + // collapse to a zero-length line at the visible boundary, not draw anything past it. + drawSpannedDecoration( + visibleEnd, + TAIL_TEXT.length, + canvas, + layout, + Color.BLACK, + TextDecorationStyle.SOLID, + ) { _, lineBaseline, thickness -> + lineBaseline + thickness + 1f + } + + verify(canvas).drawLine(eq(x), eq(baseline + 1f), eq(x), eq(baseline + 1f), any()) + } + + /** + * A single line, tail-ellipsized right after "Hello" because the paragraph break in [TAIL_TEXT] + * hides everything after it once `maxLines` is reached. + */ + private fun buildTailEllipsizedLayout(): StaticLayout { + val paint = TextPaint().apply { textSize = 32f } + val layout = + StaticLayout.Builder.obtain(TAIL_TEXT, 0, TAIL_TEXT.length, paint, 400) + .setMaxLines(1) + .setEllipsize(TextUtils.TruncateAt.END) + .build() + assertThat(layout.lineCount).isEqualTo(1) + assertThat(layout.getEllipsisCount(0)).isGreaterThan(0) + return layout + } + + private companion object { + const val TAIL_TEXT = "Hello\ndecorated world" + } } From f17f4127c7af3d03ba939beb54e4d0fcef0794b0 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 13 Oct 2026 16:32:59 -0700 Subject: [PATCH 2/2] Skip empty text decoration draws (#59050) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59050 `drawSpannedDecoration` now skips zero-line layouts and empty or fully hidden spans before measuring or drawing. Add native regression coverage for the clamped ranges. Changelog: [Android][Fixed] - Skip empty and fully hidden text decoration ranges Differential Revision: D125038370 --- .../react/views/text/TextDecorationStyle.kt | 10 +- .../views/text/TextDecorationStyleTest.kt | 116 +++++++++++++++--- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt index 95b4ee4e8e5b..45d7ed2032da 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt @@ -148,6 +148,12 @@ internal fun drawSpannedDecoration( style: TextDecorationStyle, yOffsetForLine: (paint: Paint, baseline: Float, thickness: Float) -> Float, ) { + if (layout.lineCount == 0) return + val visibleEnd = visibleTextEnd(layout) + val clampedStart = min(start, visibleEnd) + val clampedEnd = min(end, visibleEnd) + if (clampedStart >= clampedEnd) return + val textPaint = layout.paint val effectiveColor = if (color != Color.TRANSPARENT) { @@ -194,10 +200,6 @@ internal fun drawSpannedDecoration( } } - val visibleEnd = visibleTextEnd(layout) - val clampedStart = min(start, visibleEnd) - val clampedEnd = min(end, visibleEnd) - val startLine = layout.getLineForOffset(clampedStart) val endLine = layout.getLineForOffset(clampedEnd) for (line in startLine..endLine) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt index f0cf97cfc7f0..ebf45d24eb62 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt @@ -7,24 +7,34 @@ package com.facebook.react.views.text +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.os.Build +import android.text.Layout import android.text.StaticLayout import android.text.TextPaint import android.text.TextUtils import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.verifyNoInteractions import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.TextLayoutMode -@RequiresApi(Build.VERSION_CODES.M) +@RequiresApi(Build.VERSION_CODES.Q) @RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE, Build.VERSION_CODES.VANILLA_ICE_CREAM]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@TextLayoutMode(TextLayoutMode.Mode.REALISTIC) class TextDecorationStyleTest { @Test fun fromStringSolid() { @@ -73,11 +83,9 @@ class TextDecorationStyleTest { val baseline = layout.getLineBaseline(0).toFloat() val x1 = layout.getPrimaryHorizontal(0) val x2 = layout.getPrimaryHorizontal(visibleEnd) + val y = baseline + layout.paint.underlineThickness + 1f val canvas = mock() - // The span covers the whole string, well past what survived the ellipsis; pre-fix this - // called layout.getPrimaryHorizontal(TAIL_TEXT.length) and crashed with - // IndexOutOfBoundsException, since the ellipsized layout only resolves up to visibleEnd. drawSpannedDecoration( 0, TAIL_TEXT.length, @@ -89,31 +97,99 @@ class TextDecorationStyleTest { lineBaseline + thickness + 1f } - verify(canvas).drawLine(eq(x1), eq(baseline + 1f), eq(x2), eq(baseline + 1f), any()) + verify(canvas).drawLine(eq(x1), eq(y), eq(x2), eq(y), any()) } @Test fun drawSpannedDecorationSkipsSpanEntirelyPastTailEllipsis() { val layout = buildTailEllipsizedLayout() val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) - val baseline = layout.getLineBaseline(0).toFloat() - val x = layout.getPrimaryHorizontal(visibleEnd) - val canvas = mock() - // The whole span (e.g. a nested Text) starts after the ellipsis, fully hidden: it must - // collapse to a zero-length line at the visible boundary, not draw anything past it. - drawSpannedDecoration( - visibleEnd, - TAIL_TEXT.length, - canvas, - layout, - Color.BLACK, - TextDecorationStyle.SOLID, - ) { _, lineBaseline, thickness -> - lineBaseline + thickness + 1f + for (style in TextDecorationStyle.entries) { + val canvas = mock() + drawSpannedDecoration(visibleEnd, TAIL_TEXT.length, canvas, layout, Color.BLACK, style) { + _, + lineBaseline, + thickness -> + lineBaseline + thickness + 1f + } + verifyNoInteractions(canvas) } + } + + @Test + fun skipsDecorationsOutsideTruncatedLayout() { + val text = + "Visible first line\n\nThird paragraph\n\nHidden decorated text: decorated text outside the one-line layout" + val layout = buildLayout(text, text.indexOf('\n')) + val start = text.indexOf("decorated text outside") + assertThat(layout.lineCount).isEqualTo(1) + assertThat(layout.text.length).isEqualTo(text.length) + assertThat(layout.getLineEnd(0)).isLessThan(start) + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(start, text.length, layout, style)).isZero() + } + } - verify(canvas).drawLine(eq(x), eq(baseline + 1f), eq(x), eq(baseline + 1f), any()) + @Test + fun clipsDecorationsCrossingTruncatedLayout() { + val text = "Visible first line\nHidden decorated text" + val layout = buildLayout(text, text.indexOf('\n')) + assertThat(layout.getLineEnd(0)).isLessThan(text.length) + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(8, text.length, layout, style)).isEqualTo(1) + } + } + + @Test + fun keepsVisibleDecorations() { + val layout = buildLayout("Visible first line") + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(0, 7, layout, style)).isEqualTo(1) + } + } + + @Test + fun skipsEmptyDecorations() { + val layout = buildLayout("Visible first line") + assertThat(drawDecoration(0, 0, layout, TextDecorationStyle.SOLID)).isZero() + } + + @Test + fun skipsZeroLineLayout() { + val layout = mock() + assertThat(drawDecoration(0, 1, layout, TextDecorationStyle.SOLID)).isZero() + } + + @Test + fun keepsUntruncatedDecorations() { + val text = "Visible first line\nHidden decorated text" + val layout = buildLayout(text) + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(8, text.length, layout, style)).isEqualTo(2) + } + } + + private fun buildLayout(text: String, end: Int = text.length): StaticLayout = + StaticLayout.Builder.obtain(text, 0, end, TextPaint().apply { textSize = 20f }, 400).build() + + private fun drawDecoration( + start: Int, + end: Int, + layout: Layout, + style: TextDecorationStyle, + ): Int { + val canvas = Canvas(createBitmap(400, 200, Bitmap.Config.ARGB_8888)) + var drawnLines = 0 + drawSpannedDecoration(start, end, canvas, layout, Color.BLACK, style) { _, baseline, _ -> + drawnLines++ + baseline + } + return drawnLines } /**