From d0f03fe6590418047512c99b6598369274af858e Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Fri, 16 Oct 2026 14:13:37 -0700 Subject: [PATCH] Expose full logical text from PreparedLayoutTextView to a11y/view hierarchy (#59150) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59150 ## Summary `PreparedLayoutTextView` currently derives its exposed text from `Layout.getText()`. When Android ellipsizes a `StaticLayout`, that value is backed by `TextUtils.Ellipsizer`, whose string representation contains the rendered ellipsis instead of the complete source text. Accessibility services and view-hierarchy consumers can therefore receive truncated content. Preserve the original `CharSequence` in `PreparedLayout` and expose it from `PreparedLayoutTextView.getText()`. Drawing, hit testing, span handling, and selection continue to use the rendered layout text, so visual ellipsizing behaviour is unchanged. Changelog: [Android][Fixed] - Preserve complete text for accessibility when prepared text layouts are ellipsized Reviewed By: derekmeta Differential Revision: D125204355 --- .../react/fabric/FabricUIManager.java | 3 +- .../react/views/text/PreparedLayout.kt | 5 ++ .../views/text/PreparedLayoutTextView.kt | 13 +-- .../react/views/text/TextLayoutManager.kt | 5 +- ...aredLayoutTextViewAccessibilityTextTest.kt | 89 +++++++++++++++++++ ...erAbsoluteLayoutWithFractionalPixelTest.kt | 59 ++++-------- .../TextLayoutManagerStartOverhangTest.kt | 53 ++++------- 7 files changed, 140 insertions(+), 87 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewAccessibilityTextTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 3e58ab288c4a..ed0cbd42874c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -713,7 +713,8 @@ public PreparedLayout reusePreparedLayoutWithNewReactTags( reactTags, preparedLayout.getTextBreakStrategy(), preparedLayout.getJustificationMode(), - preparedLayout.getBaseTextAttributes()); + preparedLayout.getBaseTextAttributes(), + preparedLayout.getSourceText()); } @AnyThread diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt index 961d9e6e4722..7c7eea253254 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt @@ -23,4 +23,9 @@ internal class PreparedLayout( val textBreakStrategy: Int, val justificationMode: Int, val baseTextAttributes: TextAttributeProps, + // The full, un-ellipsized logical text. Required (no default) because when the layout is + // ellipsized `layout.text` is an Android `Ellipsizer` whose `toString()` is only the on-screen + // (truncated) text; every caller must consciously supply the complete string so accessibility + // and view-hierarchy consumers never fall back to the truncated text. + val sourceText: CharSequence, ) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index 7caac8f5496e..86b91ed41650 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -92,8 +92,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val text: CharSequence? // Avoid mangling the getter name, to allow black box E2E tests to read text content via - // reflection - @DoNotStrip get() = preparedLayout?.layout?.text + // reflection. Returns the full logical text, not `layout.text`, which can be an ellipsized + // `Ellipsizer` exposing only the on-screen (truncated) string to accessibility and the view + // hierarchy; internal rendering/hit-testing continues to use `layout.text` directly. + @DoNotStrip get() = preparedLayout?.sourceText init { // ViewGroup by default says only its children will draw @@ -136,7 +138,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context) } - val spanned = text as? Spanned + val spanned = layout.text as? Spanned val canvasEffectSpans = spanned?.getSpans(0, spanned.length, CanvasEffectSpan::class.java) ?: emptyArray() @@ -242,7 +244,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re val action = event.actionMasked if (action == MotionEvent.ACTION_CANCEL) { // Forward ACTION_CANCEL to all TouchableSpans so they can reset pressed/animation state - val spanned = text as? Spanned + val spanned = preparedLayout?.layout?.text as? Spanned for (span in spanned?.getSpans(0, spanned.length, TouchableSpan::class.java).orEmpty()) { span.onTouchEvent(action, 0f, 0f) } @@ -314,7 +316,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re return null } - val spanned = text as? Spanned ?: return null + val spanned = preparedLayout?.layout?.text as? Spanned ?: return null val spans = spanned.getSpans(offset, offset, clazz) if (spans.isEmpty()) { @@ -503,6 +505,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re textBreakStrategy, justificationMode, baseTextAttributes, + sourceText, ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index f3bac78200aa..8638363733d6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -841,7 +841,8 @@ internal object TextLayoutManager { } } - private fun createLayout( + @VisibleForTesting + internal fun createLayout( text: Spannable, boring: BoringLayout.Metrics?, width: Float, @@ -1356,6 +1357,8 @@ internal object TextLayoutManager { result.textBreakStrategy, result.justificationMode, baseTextAttributes, + // `result.layout.text` may be ellipsized; keep the full text for a11y. + sourceText = text, ) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewAccessibilityTextTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewAccessibilityTextTest.kt new file mode 100644 index 000000000000..48869cc20250 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewAccessibilityTextTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.annotation.SuppressLint +import android.text.Layout +import android.text.SpannableString +import android.text.TextPaint +import android.text.TextUtils +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.common.mapbuffer.WritableMapBuffer +import com.facebook.yoga.YogaMeasureMode +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Regression test for prepared text layout exposing truncated text to accessibility / the view + * hierarchy. + * + * When a prepared [Layout] is ellipsized, `layout.text` is an Android `Ellipsizer` whose + * `toString()` is only the on-screen (truncated) string. [PreparedLayoutTextView.getText] — read by + * the accessibility node and by black-box E2E view-hierarchy tooling — must instead return the full + * logical text, matching a non-prepared `TextView` whose `getText()` returns the full source. + */ +@OptIn(UnstableReactNativeAPI::class) +@RunWith(RobolectricTestRunner::class) +@SuppressLint("NewApi") +class PreparedLayoutTextViewAccessibilityTextTest { + + private val fullText = + listOf( + "This is an order created from the data builder", + "second line of the description", + "third line of the description", + ) + .joinToString("\n") + + @Test + @Config(sdk = [34]) + fun `getText returns the full logical text when the prepared layout is ellipsized`() { + val layout = createEllipsizedLayout() + + // Guard: the scenario is genuinely ellipsized, so layout.text is the truncated on-screen text. + assertThat(layout.getEllipsisCount(layout.lineCount - 1)).isGreaterThan(0) + assertThat(layout.text.toString()).contains("…") + assertThat(layout.text.toString()).isNotEqualTo(fullText) + + val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + view.preparedLayout = + PreparedLayout( + layout, + /* maximumNumberOfLines = */ 1, + /* verticalOffset = */ 0f, + /* reactTags = */ IntArray(0), + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* justificationMode = */ 0, + baseTextAttributes = TextAttributeProps.fromMapBuffer(WritableMapBuffer()), + /* sourceText = */ fullText, + ) + + assertThat(view.text?.toString()).isEqualTo(fullText) + assertThat(view.text?.toString()).doesNotContain("…") + } + + private fun createEllipsizedLayout(): Layout = + TextLayoutManager.createLayout( + SpannableString(fullText), + /* boring = */ null, + /* width = */ 200f, + YogaMeasureMode.EXACTLY, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ TextUtils.TruncateAt.END, + /* maxNumberOfLines = */ 1, + TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f }, + ) +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt index 33e00f0af10c..71a0eba93927 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt @@ -12,7 +12,6 @@ import android.text.BoringLayout import android.text.Layout import android.text.SpannableString import android.text.TextPaint -import android.text.TextUtils import com.facebook.yoga.YogaMeasureMode import kotlin.math.ceil import org.assertj.core.api.Assertions.assertThat @@ -78,9 +77,8 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest { } /** - * Invokes the private TextLayoutManager.createLayout via reflection. We can't call it directly - * because it's `private` (friend_paths only opens up `internal`). Default values mirror what - * measureText() passes in the production path for a plain single-paragraph label. + * Default values mirror what measureText() passes in the production path for a plain + * single-paragraph label. * * BREAK_STRATEGY_HIGH_QUALITY and HYPHENATION_FREQUENCY_NONE are API 23+ constants. The test runs * only on Robolectric (JVM), never on a device, so the inlined integer values are safe. @@ -90,42 +88,19 @@ class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest { text: SpannableString, width: Float, paint: TextPaint, - ): Layout { - val boring: BoringLayout.Metrics? = BoringLayout.isBoring(text, paint) - val method = - TextLayoutManager::class - .java - .getDeclaredMethod( - "createLayout", - android.text.Spannable::class.java, - BoringLayout.Metrics::class.java, - java.lang.Float.TYPE, - YogaMeasureMode::class.java, - java.lang.Boolean.TYPE, - java.lang.Integer.TYPE, - java.lang.Integer.TYPE, - Layout.Alignment::class.java, - java.lang.Integer.TYPE, - TextUtils.TruncateAt::class.java, - java.lang.Integer.TYPE, - TextPaint::class.java, - ) - .apply { isAccessible = true } - - return method.invoke( - TextLayoutManager, - text, - boring, - width, - YogaMeasureMode.EXACTLY, - /* includeFontPadding = */ false, - /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, - /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, - Layout.Alignment.ALIGN_NORMAL, - /* justificationMode = */ 0, - /* ellipsizeMode = */ null, - /* maxNumberOfLines = */ 2, - paint, - ) as Layout - } + ): Layout = + TextLayoutManager.createLayout( + text, + BoringLayout.isBoring(text, paint), + width, + YogaMeasureMode.EXACTLY, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ null, + /* maxNumberOfLines = */ 2, + paint, + ) } 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 2f23db9c3047..c779cd62aeac 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 @@ -9,7 +9,6 @@ package com.facebook.react.views.text import android.annotation.SuppressLint import android.graphics.RectF -import android.text.BoringLayout import android.text.Layout import android.text.SpannableString import android.text.TextPaint @@ -132,43 +131,21 @@ class TextLayoutManagerStartOverhangTest { ellipsizeMode: TextUtils.TruncateAt? = null, maxNumberOfLines: Int = 2, paint: TextPaint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f }, - ): Layout { - val method = - TextLayoutManager::class - .java - .getDeclaredMethod( - "createLayout", - android.text.Spannable::class.java, - BoringLayout.Metrics::class.java, - java.lang.Float.TYPE, - YogaMeasureMode::class.java, - java.lang.Boolean.TYPE, - java.lang.Integer.TYPE, - java.lang.Integer.TYPE, - Layout.Alignment::class.java, - java.lang.Integer.TYPE, - TextUtils.TruncateAt::class.java, - java.lang.Integer.TYPE, - TextPaint::class.java, - ) - .apply { isAccessible = true } - - return method.invoke( - TextLayoutManager, - text, - null, - layoutWidth, - widthMode, - /* includeFontPadding = */ false, - /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, - /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, - Layout.Alignment.ALIGN_NORMAL, - /* justificationMode = */ 0, - /* ellipsizeMode = */ ellipsizeMode, - /* maxNumberOfLines = */ maxNumberOfLines, - paint, - ) as Layout - } + ): Layout = + TextLayoutManager.createLayout( + text, + /* boring = */ null, + layoutWidth, + widthMode, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ ellipsizeMode, + /* maxNumberOfLines = */ maxNumberOfLines, + paint, + ) private fun getBooleanLayoutProperty(layout: Layout, methodName: String): Boolean = layout.javaClass.getMethod(methodName).invoke(layout) as Boolean