From 4cd2d06bdab803707ed5ed7f3d9df7b74c98ca1a Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Wed, 28 Oct 2026 19:03:08 +0100 Subject: [PATCH 1/2] Find a Text's clickable spans only when a touch needs them PreparedLayoutTextView collected the ClickableSpans of its text every time the prepared layout changed, but the list was only checked for being empty, by touch and focus handling. Touch handling now checks whether the text has a ClickableSpan on first use and forgets the answer when the layout changes. Losing focus clears the selection when there is one, which is the state it resets, instead of looking at the spans. Co-Authored-By: Claude Opus 5.5 --- .../views/text/PreparedLayoutTextView.kt | 35 ++--- ...PreparedLayoutTextViewClickableSpanTest.kt | 129 ++++++++++++++++++ 2 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt 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 86b91ed41650..fab6d6d44195 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 @@ -48,7 +48,9 @@ import kotlin.math.roundToInt @DoNotStrip internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), ReactCompoundView { - private var clickableSpans: List = emptyList() + // Whether the text has a ClickableSpan. Only touch handling needs it, so it is found on first use + // rather than on every text update; null until then. + private var hasClickableSpansCache: Boolean? = null private var selection: TextSelection? = null private var lastFrameTimeNanos: Long = 0L @@ -72,8 +74,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } } - clickableSpans = - effectiveValue?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() + hasClickableSpansCache = null field = effectiveValue invalidate() @@ -105,7 +106,6 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re fun recycleView() { BackgroundStyleApplicator.reset(this) overflow = Overflow.VISIBLE - clickableSpans = emptyList() selection = null selectionColor = null lastFrameTimeNanos = 0L @@ -277,7 +277,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } // Existing ClickableSpan handling - if (clickableSpans.isEmpty()) { + if (!hasClickableSpans()) { return super.onTouchEvent(event) } @@ -301,6 +301,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re return true } + private fun hasClickableSpans(): Boolean = + hasClickableSpansCache + ?: hasClickableSpan(preparedLayout?.layout?.text).also { hasClickableSpansCache = it } + private fun findRootView(): RootView? { var p: ViewParent? = parent while (p != null) { @@ -413,7 +417,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re direction: Int, previouslyFocusedRect: Rect?, ) { - if (clickableSpans.isNotEmpty() && !gainFocus) { + if (!gainFocus && selection != null) { clearSelection() } super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) @@ -475,21 +479,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re private companion object { private val selectionPaint = Paint() - private fun filterClickableSpans(text: CharSequence): List { - if (text !is Spanned) { - return emptyList() - } - - val spans = ArrayList() - var i = 0 - while (i < text.length) { - val next = text.nextSpanTransition(i, text.length, ClickableSpan::class.java) - spans.addAll(text.getSpans(i, next, ClickableSpan::class.java)) - i = next - } - - return spans - } + private fun hasClickableSpan(text: CharSequence?): Boolean = + text is Spanned && + text.isNotEmpty() && + text.getSpans(0, text.length, ClickableSpan::class.java).isNotEmpty() /** * If the layout contains [StatefulSpan]s, returns a new [PreparedLayout] whose spannable has diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt new file mode 100644 index 000000000000..7357ba21c04a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt @@ -0,0 +1,129 @@ +/* + * 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.graphics.text.LineBreakConfig +import android.os.SystemClock +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.MotionEvent +import android.view.View +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 +import org.robolectric.annotation.GraphicsMode + +/** Tapping a [ClickableSpan] in a [PreparedLayoutTextView] clicks it, for the current text only. */ +@OptIn(UnstableReactNativeAPI::class) +@RunWith(RobolectricTestRunner::class) +@SuppressLint("NewApi") +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PreparedLayoutTextViewClickableSpanTest { + + private val view = PreparedLayoutTextView(RuntimeEnvironment.getApplication()) + + @Test + fun `tapping a clickable span clicks it`() { + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + + assertThat(tap()).isTrue() + assertThat(span.clicks).isEqualTo(1) + } + + @Test + fun `tapping text without clickable spans does not click`() { + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + tap() + + view.preparedLayout = preparedLayout(SpannableString(TEXT)) + + assertThat(tap()).isFalse() + assertThat(span.clicks).isEqualTo(1) + } + + @Test + fun `tapping a clickable span in new text clicks it`() { + view.preparedLayout = preparedLayout(SpannableString(TEXT)) + tap() + + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + + assertThat(tap()).isTrue() + assertThat(span.clicks).isEqualTo(1) + } + + /** Sends a down and an up at the start of the text; returns whether the up was handled. */ + private fun tap(): Boolean { + val time = SystemClock.uptimeMillis() + val down = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 5f, 5f, 0) + val up = MotionEvent.obtain(time, time, MotionEvent.ACTION_UP, 5f, 5f, 0) + view.onTouchEvent(down) + val handled = view.onTouchEvent(up) + down.recycle() + up.recycle() + return handled + } + + private fun textWith(span: ClickableSpan): SpannableString = + SpannableString(TEXT).apply { + setSpan(span, 0, TEXT.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE) + } + + private fun preparedLayout(text: SpannableString): PreparedLayout = + PreparedLayout( + TextLayoutManager.createLayout( + text, + /* boring = */ null, + /* width = */ 400f, + YogaMeasureMode.EXACTLY, + /* includeFontPadding = */ false, + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE, + /* lineBreakStyle = */ LineBreakConfig.LINE_BREAK_STYLE_NONE, + /* lineBreakWordStyle = */ LineBreakConfig.LINE_BREAK_WORD_STYLE_NONE, + Layout.Alignment.ALIGN_NORMAL, + /* justificationMode = */ 0, + /* ellipsizeMode = */ null, + /* maxNumberOfLines = */ 0, + TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 26f }, + ), + /* maximumNumberOfLines = */ 0, + /* verticalOffset = */ 0f, + /* reactTags = */ IntArray(0), + /* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY, + /* justificationMode = */ 0, + baseTextAttributes = TextAttributeProps.fromMapBuffer(WritableMapBuffer()), + /* sourceText = */ text, + ) + + private class CountingClickableSpan : ClickableSpan() { + var clicks = 0 + + override fun onClick(widget: View) { + clicks++ + } + } + + private companion object { + const val TEXT = "Tap this text" + } +} From e5873cecf1f5f12198d46108bb43856d6052f39a Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 2 Nov 2026 19:20:34 +0100 Subject: [PATCH 2/2] Clear the clickable span cache when a text view is recycled Setting `preparedLayout` to null already resets it, but clear it explicitly too, like the other per-text state in recycleView(). Co-Authored-By: Claude Opus 5.5 --- .../react/views/text/PreparedLayoutTextView.kt | 1 + .../text/PreparedLayoutTextViewClickableSpanTest.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+) 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 fab6d6d44195..e1ae6ac7ff6e 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 @@ -110,6 +110,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor = null lastFrameTimeNanos = 0L preparedLayout = null + hasClickableSpansCache = null } override fun onVisibilityChanged(changedView: View, visibility: Int) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt index 7357ba21c04a..b327e609555c 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/PreparedLayoutTextViewClickableSpanTest.kt @@ -71,6 +71,18 @@ class PreparedLayoutTextViewClickableSpanTest { assertThat(span.clicks).isEqualTo(1) } + @Test + fun `a recycled view does not click spans of its previous text`() { + val span = CountingClickableSpan() + view.preparedLayout = preparedLayout(textWith(span)) + tap() + + view.recycleView() + + assertThat(tap()).isFalse() + assertThat(span.clicks).isEqualTo(1) + } + /** Sends a down and an up at the start of the text; returns whether the up was handled. */ private fun tap(): Boolean { val time = SystemClock.uptimeMillis()