From 077338487664c77a3d47165355de390878abd5e3 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Sun, 15 Feb 2026 17:48:07 -0800 Subject: [PATCH 1/2] Initial support for selectable text with enablePreparedTextLayout (#55552) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/55552 enablePreparedTextLayout replaces ReactTextView (a real TextView) with PreparedLayoutTextView (a ViewGroup that draws a pre-computed Layout). PreparedLayoutTextView does not support native text selection, so selectable text was broken when the flag was on (T222052152). This diff adds support for selectable text by routing it through ReactTextView when enablePreparedTextLayout is enabled. A new JS component NativeSelectableText resolves to native name RCTSelectableText when the flag is on, or falls back to RCTText when it is off. Text.js uses NativeSelectableText whenever text is selectable, and a new SelectableTextViewManager (which extends ReactTextViewManager) is registered as RCTSelectableText in all ReactPackage sites. ReactTextViewManager.updateState() is also updated to handle ReferenceStateWrapper holding PreparedLayout, so that it can process state delivered through the PreparedLayout path. Note that this change relies on https://github.com/facebook/react/pull/35780 to avoid warnings from React Changelog: [Internal] Differential Revision: D92928315 Reviewed By: mdvacca --- packages/react-native/Libraries/Text/Text.js | 69 +++++++++---------- .../Libraries/Text/TextNativeComponent.js | 13 ++++ .../ReactAndroid/api/ReactAndroid.api | 10 ++- .../mountitems/FabricNameComponentMapping.kt | 1 + .../facebook/react/shell/MainReactPackage.kt | 6 ++ .../text/PreparedLayoutTextViewManager.kt | 5 +- .../react/views/text/ReactTextViewManager.kt | 37 +++++++++- .../views/text/SelectableTextViewManager.kt | 29 ++++++++ .../react/fabric/CoreComponentsRegistry.cpp | 4 ++ .../componentNameByReactViewName.cpp | 3 + .../text/BaseParagraphComponentDescriptor.h | 49 +++++++++++++ .../text/ParagraphComponentDescriptor.cpp | 14 ---- .../text/ParagraphComponentDescriptor.h | 31 +-------- .../components/text/ParagraphShadowNode.h | 2 +- .../SelectableParagraphComponentDescriptor.h | 24 +++++++ .../text/SelectableParagraphShadowNode.h | 32 +++++++++ packages/react-native/ReactNativeApi.d.ts | 10 +-- 17 files changed, 252 insertions(+), 87 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SelectableTextViewManager.kt create mode 100644 packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphComponentDescriptor.h delete mode 100644 packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphComponentDescriptor.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphShadowNode.h diff --git a/packages/react-native/Libraries/Text/Text.js b/packages/react-native/Libraries/Text/Text.js index a31b20a918f8..9bfc163f9f5d 100644 --- a/packages/react-native/Libraries/Text/Text.js +++ b/packages/react-native/Libraries/Text/Text.js @@ -20,14 +20,18 @@ import flattenStyle from '../StyleSheet/flattenStyle'; import processColor from '../StyleSheet/processColor'; import Platform from '../Utilities/Platform'; import TextAncestorContext from './TextAncestorContext'; -import {NativeText, NativeVirtualText} from './TextNativeComponent'; +import { + NativeSelectableText, + NativeText, + NativeVirtualText, +} from './TextNativeComponent'; import * as React from 'react'; import {useContext, useMemo, useState} from 'react'; export type {TextProps} from './TextProps'; type TextForwardRef = React.ElementRef< - typeof NativeText | typeof NativeVirtualText, + typeof NativeText | typeof NativeVirtualText | typeof NativeSelectableText, >; /** @@ -263,7 +267,7 @@ const TextImpl: component( processedProps.children = children; if (isPressable) { return ( - ); } else { - nativeText = ; + nativeText = + _selectable === true ? ( + + ) : ( + + ); } if (children == null) { @@ -457,28 +467,17 @@ function useTextPressability({ ); } -type NativePressableTextProps = Readonly<{ - textProps: NativeTextProps, - textPressabilityProps: TextPressabilityProps, -}>; - /** * Wrap the NativeVirtualText component and initialize pressability. * * This logic is split out from the main Text component to enable the more * expensive pressability logic to be only initialized when needed. */ -const NativePressableVirtualText: component( - ref: React.RefSetter, - ...props: NativePressableTextProps -) = ({ - ref: forwardedRef, - textProps, - textPressabilityProps, -}: { +component PressableVirtualText( ref?: React.RefSetter, - ...NativePressableTextProps, -}) => { + textProps: NativeTextProps, + textPressabilityProps: TextPressabilityProps, +) { const [isHighlighted, eventHandlersForText] = useTextPressability( textPressabilityProps, ); @@ -489,42 +488,40 @@ const NativePressableVirtualText: component( {...eventHandlersForText} isHighlighted={isHighlighted} isPressable={true} - ref={forwardedRef} + ref={ref} /> ); -}; +} /** - * Wrap the NativeText component and initialize pressability. + * Wrap a NativeText component and initialize pressability. * * This logic is split out from the main Text component to enable the more * expensive pressability logic to be only initialized when needed. */ -const NativePressableText: component( - ref: React.RefSetter, - ...props: NativePressableTextProps -) = ({ - ref: forwardedRef, - textProps, - textPressabilityProps, -}: { +component PressableText( ref?: React.RefSetter, - ...NativePressableTextProps, -}) => { + selectable?: ?boolean, + textProps: NativeTextProps, + textPressabilityProps: TextPressabilityProps, +) { const [isHighlighted, eventHandlersForText] = useTextPressability( textPressabilityProps, ); + const NativeComponent = + selectable === true ? NativeSelectableText : NativeText; + return ( - ); -}; +} const userSelectToSelectableMap = { auto: true, diff --git a/packages/react-native/Libraries/Text/TextNativeComponent.js b/packages/react-native/Libraries/Text/TextNativeComponent.js index 020d5ceea8b6..01b7c690fd81 100644 --- a/packages/react-native/Libraries/Text/TextNativeComponent.js +++ b/packages/react-native/Libraries/Text/TextNativeComponent.js @@ -13,6 +13,7 @@ import type {ProcessedColorValue} from '../StyleSheet/processColor'; import type {GestureResponderEvent} from '../Types/CoreEventTypes'; import type {TextProps} from './TextProps'; +import {enablePreparedTextLayout} from '../../src/private/featureflags/ReactNativeFeatureFlags'; import {createViewConfig} from '../NativeComponent/ViewConfig'; import UIManager from '../ReactNative/UIManager'; import createReactNativeComponentClass from '../Renderer/shims/createReactNativeComponentClass'; @@ -90,3 +91,15 @@ export const NativeVirtualText: HostComponent = * https://fburl.com/workplace/6291gfvu */ createViewConfig(virtualTextViewConfig), ): any); + +export const NativeSelectableText: HostComponent = + enablePreparedTextLayout() + ? (createReactNativeComponentClass('RCTSelectableText', () => + /* $FlowFixMe[incompatible-type] Natural Inference rollout. See + * https://fburl.com/workplace/6291gfvu */ + createViewConfig({ + ...textViewConfig, + uiViewClassName: 'RCTSelectableText', + }), + ): any) + : NativeText; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 80c168cd95d1..18d8f7aafaa8 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6112,7 +6112,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi public fun updateView ()V } -public final class com/facebook/react/views/text/ReactTextViewManager : com/facebook/react/uimanager/BaseViewManager, com/facebook/react/uimanager/IViewManagerWithChildren, com/facebook/react/views/text/ReactTextViewManagerCallback { +public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/react/uimanager/BaseViewManager, com/facebook/react/uimanager/IViewManagerWithChildren, com/facebook/react/views/text/ReactTextViewManagerCallback { public static final field Companion Lcom/facebook/react/views/text/ReactTextViewManager$Companion; public static final field REACT_CLASS Ljava/lang/String; public fun ()V @@ -6125,11 +6125,14 @@ public final class com/facebook/react/views/text/ReactTextViewManager : com/face public fun createViewInstance (Lcom/facebook/react/uimanager/ThemedReactContext;)Lcom/facebook/react/views/text/ReactTextView; public fun getExportedCustomDirectEventTypeConstants ()Ljava/util/Map; public fun getName ()Ljava/lang/String; + protected final fun getReactTextViewManagerCallback ()Lcom/facebook/react/views/text/ReactTextViewManagerCallback; public fun getShadowNodeClass ()Ljava/lang/Class; public fun needsCustomLayoutForChildren ()Z public synthetic fun onAfterUpdateTransaction (Landroid/view/View;)V + protected fun onAfterUpdateTransaction (Lcom/facebook/react/views/text/ReactTextView;)V public fun onPostProcessSpannable (Landroid/text/Spannable;)V public synthetic fun prepareToRecycleView (Lcom/facebook/react/uimanager/ThemedReactContext;Landroid/view/View;)Landroid/view/View; + protected fun prepareToRecycleView (Lcom/facebook/react/uimanager/ThemedReactContext;Lcom/facebook/react/views/text/ReactTextView;)Lcom/facebook/react/views/text/ReactTextView; public final fun setAccessible (Lcom/facebook/react/views/text/ReactTextView;Z)V public final fun setAdjustFontSizeToFit (Lcom/facebook/react/views/text/ReactTextView;Z)V public final fun setAndroidHyphenationFrequency (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V @@ -6147,6 +6150,7 @@ public final class com/facebook/react/views/text/ReactTextViewManager : com/face public final fun setOverflow (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V public synthetic fun setPadding (Landroid/view/View;IIII)V public fun setPadding (Lcom/facebook/react/views/text/ReactTextView;IIII)V + protected final fun setReactTextViewManagerCallback (Lcom/facebook/react/views/text/ReactTextViewManagerCallback;)V public final fun setSelectable (Lcom/facebook/react/views/text/ReactTextView;Z)V public final fun setSelectionColor (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/Integer;)V public final fun setTextAlignVertical (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V @@ -6155,6 +6159,7 @@ public final class com/facebook/react/views/text/ReactTextViewManager : com/face public synthetic fun updateState (Landroid/view/View;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object; public fun updateState (Lcom/facebook/react/views/text/ReactTextView;Lcom/facebook/react/uimanager/ReactStylesDiffMap;Lcom/facebook/react/uimanager/StateWrapper;)Ljava/lang/Object; public synthetic fun updateViewAccessibility (Landroid/view/View;)V + protected fun updateViewAccessibility (Lcom/facebook/react/views/text/ReactTextView;)V } public final class com/facebook/react/views/text/ReactTextViewManager$Companion { @@ -6172,6 +6177,9 @@ public final class com/facebook/react/views/text/ReactTypefaceUtils { public static final fun parseFontWeight (Ljava/lang/String;)I } +public final class com/facebook/react/views/text/SelectableTextViewManager$Companion { +} + public final class com/facebook/react/views/text/TextAttributeProps { public static final field Companion Lcom/facebook/react/views/text/TextAttributeProps$Companion; public static final field TA_KEY_ACCESSIBILITY_ROLE I diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt index c032448a7a33..31d93c32b695 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt @@ -18,6 +18,7 @@ internal object FabricNameComponentMapping { "Slider" to "RCTSlider", "ModalHostView" to "RCTModalHostView", "Paragraph" to "RCTText", + "SelectableParagraph" to "RCTSelectableText", "Text" to "RCTText", "RawText" to "RCTRawText", "ActivityIndicatorView" to "AndroidProgressBar", diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt index 9927bf571536..b3baad46a9aa 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt @@ -15,6 +15,7 @@ import com.facebook.react.bridge.ModuleSpec import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.common.ClassFinder +import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.module.annotations.ReactModule import com.facebook.react.module.annotations.ReactModuleList @@ -58,6 +59,7 @@ import com.facebook.react.views.swiperefresh.SwipeRefreshLayoutManager import com.facebook.react.views.switchview.ReactSwitchManager import com.facebook.react.views.text.PreparedLayoutTextViewManager import com.facebook.react.views.text.ReactTextViewManager +import com.facebook.react.views.text.SelectableTextViewManager import com.facebook.react.views.textinput.ReactTextInputManager import com.facebook.react.views.unimplementedview.ReactUnimplementedViewManager import com.facebook.react.views.view.ReactViewManager @@ -96,6 +98,7 @@ import com.facebook.react.views.view.ReactViewManager WebSocketModule::class, ] ) +@OptIn(UnstableReactNativeAPI::class) public class MainReactPackage @JvmOverloads constructor(private val config: MainPackageConfig? = null) : @@ -150,6 +153,7 @@ constructor(private val config: MainPackageConfig? = null) : ReactTextInputManager(), if (ReactNativeFeatureFlags.enablePreparedTextLayout()) PreparedLayoutTextViewManager() else ReactTextViewManager(), + SelectableTextViewManager(), ReactViewManager(), ReactUnimplementedViewManager(), ) @@ -192,6 +196,8 @@ constructor(private val config: MainPackageConfig? = null) : PreparedLayoutTextViewManager() else ReactTextViewManager() }, + SelectableTextViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { SelectableTextViewManager() }, ReactViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactViewManager() }, ReactUnimplementedViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactUnimplementedViewManager() }, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt index ede2a678a1fe..66a2bf753135 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextViewManager.kt @@ -118,8 +118,9 @@ internal class PreparedLayoutTextViewManager : @ReactProp(name = "selectable", defaultBoolean = false) fun setSelectable(view: PreparedLayoutTextView, isSelectable: Boolean): Unit { - // T222052152: Implement fine-grained text selection for PreparedLayoutTextView - // view.setTextIsSelectable(isSelectable); + check(!isSelectable) { + "selectable Text should use SelectableTextViewManager instead of PreparedLayoutViewManager" + } } @ReactProp(name = "selectionColor", customType = "Color") diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index 2e3669c113d7..e616cf5f6e75 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -12,6 +12,7 @@ package com.facebook.react.views.text import android.os.Build import android.text.Layout import android.text.Spannable +import android.text.SpannableString import android.text.Spanned import android.text.TextUtils import android.text.util.Linkify @@ -31,6 +32,7 @@ import com.facebook.react.uimanager.LayoutShadowNode import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.ReferenceStateWrapper import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewDefaults @@ -46,7 +48,7 @@ import java.util.HashMap /** View manager for `` nodes. */ @ReactModule(name = ReactTextViewManager.REACT_CLASS) @OptIn(UnstableReactNativeAPI::class) -public class ReactTextViewManager +public open class ReactTextViewManager @JvmOverloads public constructor( protected var reactTextViewManagerCallback: ReactTextViewManagerCallback? = null @@ -131,6 +133,11 @@ public constructor( stateWrapper: StateWrapper, ): Any? { SystraceSection("ReactTextViewManager.updateState").use { s -> + val refState = (stateWrapper as? ReferenceStateWrapper)?.stateDataReference + if (refState is PreparedLayout) { + return getReactTextUpdateFromPreparedLayout(view, refState) + } + val stateMapBuffer = stateWrapper.stateDataMapBuffer return if (stateMapBuffer != null) { getReactTextUpdate(view, props, stateMapBuffer) @@ -176,6 +183,34 @@ public constructor( ) } + /** + * Constructs a [ReactTextUpdate] from a [PreparedLayout] received via [ReferenceStateWrapper]. + */ + private fun getReactTextUpdateFromPreparedLayout( + view: ReactTextView, + preparedLayout: PreparedLayout, + ): ReactTextUpdate { + val layout = preparedLayout.layout + val text = layout.text + val spanned = if (text is Spannable) text else SpannableString(text) + view.setSpanned(spanned) + + val textAlign = + when (layout.alignment) { + Layout.Alignment.ALIGN_CENTER -> Gravity.CENTER_HORIZONTAL + Layout.Alignment.ALIGN_OPPOSITE -> Gravity.END + else -> Gravity.START + } + + return ReactTextUpdate( + spanned, + -1, + textAlign, + Layout.BREAK_STRATEGY_HIGH_QUALITY, + 0, + ) + } + override fun getExportedCustomDirectEventTypeConstants(): MutableMap? { val baseEventTypeConstants = super.getExportedCustomDirectEventTypeConstants() val eventTypeConstants = baseEventTypeConstants ?: HashMap() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SelectableTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SelectableTextViewManager.kt new file mode 100644 index 000000000000..b1f15c461903 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/SelectableTextViewManager.kt @@ -0,0 +1,29 @@ +/* + * 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 com.facebook.react.common.annotations.UnstableReactNativeAPI + +/** + * A [ReactTextViewManager] registered under the name "RCTSelectableText". Used to route selectable + * text through [ReactTextView] (a real [android.widget.TextView]) instead of + * [PreparedLayoutTextView] when enablePreparedTextLayout is on, since [PreparedLayoutTextView] does + * not support native text selection. + */ +@UnstableReactNativeAPI +public class SelectableTextViewManager +@JvmOverloads +public constructor(reactTextViewManagerCallback: ReactTextViewManagerCallback? = null) : + ReactTextViewManager(reactTextViewManagerCallback) { + + override fun getName(): String = REACT_CLASS + + public companion object { + public const val REACT_CLASS: String = "RCTSelectableText" + } +} diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp index 03d455ee92f4..3b15aa501986 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,9 @@ void addCoreComponents( AndroidHorizontalScrollContentViewComponentDescriptor>()); providerRegistry->add( concreteComponentDescriptorProvider()); + providerRegistry->add( + concreteComponentDescriptorProvider< + SelectableParagraphComponentDescriptor>()); providerRegistry->add( concreteComponentDescriptorProvider< AndroidDrawerLayoutComponentDescriptor>()); diff --git a/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp b/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp index 25e3b2cb1072..fe63407de827 100644 --- a/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp +++ b/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp @@ -27,6 +27,9 @@ std::string componentNameByReactViewName(std::string viewName) { if (viewName == "Text") { return "Paragraph"; } + if (viewName == "SelectableText") { + return "SelectableParagraph"; + } if (viewName == "VirtualText") { return "Text"; diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphComponentDescriptor.h b/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphComponentDescriptor.h new file mode 100644 index 000000000000..8c7c8aab798f --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/text/BaseParagraphComponentDescriptor.h @@ -0,0 +1,49 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +namespace facebook::react { + +constexpr const char *const TextLayoutManagerKey = "TextLayoutManager"; + +template +class BaseParagraphComponentDescriptor : public ConcreteComponentDescriptor { + public: + explicit BaseParagraphComponentDescriptor(const ComponentDescriptorParameters ¶meters) + : ConcreteComponentDescriptor(parameters), + textLayoutManager_(getManagerByName(this->contextContainer_, TextLayoutManagerKey)) + { + } + + ComponentName getComponentName() const override + { + return ShadowNodeT::Name(); + } + + protected: + void adopt(ShadowNode &shadowNode) const override + { + ConcreteComponentDescriptor::adopt(shadowNode); + + auto ¶graphShadowNode = static_cast(shadowNode); + + // `ParagraphShadowNode` uses `TextLayoutManager` to measure text content + // and communicate text rendering metrics to mounting layer. + paragraphShadowNode.setTextLayoutManager(textLayoutManager_); + } + + private: + // Every `ParagraphShadowNode` has a reference to a shared `TextLayoutManager` + const std::shared_ptr textLayoutManager_; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.cpp deleted file mode 100644 index ee4e014b6564..000000000000 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.cpp +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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. - */ - -#include "ParagraphComponentDescriptor.h" - -namespace facebook::react { - -extern const char TextLayoutManagerKey[] = "TextLayoutManager"; - -} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.h b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.h index 3ed1a2a7aefc..3f64c6e8cb7a 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.h +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphComponentDescriptor.h @@ -7,41 +7,16 @@ #pragma once +#include #include -#include -#include -#include namespace facebook::react { - -extern const char TextLayoutManagerKey[]; - /* * Descriptor for component. */ -class ParagraphComponentDescriptor final : public ConcreteComponentDescriptor { +class ParagraphComponentDescriptor final : public BaseParagraphComponentDescriptor { public: - explicit ParagraphComponentDescriptor(const ComponentDescriptorParameters ¶meters) - : ConcreteComponentDescriptor(parameters), - textLayoutManager_(getManagerByName(contextContainer_, TextLayoutManagerKey)) - { - } - - protected: - void adopt(ShadowNode &shadowNode) const override - { - ConcreteComponentDescriptor::adopt(shadowNode); - - auto ¶graphShadowNode = static_cast(shadowNode); - - // `ParagraphShadowNode` uses `TextLayoutManager` to measure text content - // and communicate text rendering metrics to mounting layer. - paragraphShadowNode.setTextLayoutManager(textLayoutManager_); - } - - private: - // Every `ParagraphShadowNode` has a reference to a shared `TextLayoutManager` - const std::shared_ptr textLayoutManager_; + using BaseParagraphComponentDescriptor::BaseParagraphComponentDescriptor; }; } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h index 91709d035d05..c518ccf4b90b 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h @@ -26,7 +26,7 @@ extern const char ParagraphComponentName[]; * containing and displaying text. Text content is represented as nested * and components. */ -class ParagraphShadowNode final +class ParagraphShadowNode : public ConcreteViewShadowNode, public BaseTextShadowNode { public: diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphComponentDescriptor.h b/packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphComponentDescriptor.h new file mode 100644 index 000000000000..b008580f5885 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphComponentDescriptor.h @@ -0,0 +1,24 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +namespace facebook::react { +/* + * Descriptor for component, which may render to a + * different native view than . + */ +class SelectableParagraphComponentDescriptor final + : public BaseParagraphComponentDescriptor { + public: + using BaseParagraphComponentDescriptor::BaseParagraphComponentDescriptor; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphShadowNode.h new file mode 100644 index 000000000000..8574dd601abd --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/text/SelectableParagraphShadowNode.h @@ -0,0 +1,32 @@ +/* + * 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. + */ + +#pragma once + +#include + +namespace facebook::react { + +/* + * ShadowNode for selectable Paragraph components, which may map to different native component than Paragraph. + */ +class SelectableParagraphShadowNode : public ParagraphShadowNode { + public: + using ParagraphShadowNode::ParagraphShadowNode; + + static constexpr ComponentName Name() + { + return "SelectableParagraph"; + } + + static ComponentHandle Handle() + { + return ComponentHandle(Name()); + } +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 9229bb4336ff..3cb8fa700fc8 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<6463b0a4f3acbeb07e6759c345b927d8>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -322,6 +322,7 @@ declare const NativeModules: typeof NativeModules_default declare let NativeModules_default: { [moduleName: string]: any } +declare const NativeSelectableText: HostComponent declare const NativeText: HostComponent declare const NativeTouchable: | typeof TouchableNativeFeedback @@ -3368,6 +3369,7 @@ declare type NativeScrollVelocity = { readonly x: number readonly y: number } +declare type NativeSelectableText = typeof NativeSelectableText declare type NativeSwitchChangeEvent = { readonly target: Int32 readonly value: boolean @@ -5160,7 +5162,7 @@ declare type TextContentType = | "URL" | "username" declare type TextForwardRef = React.ComponentRef< - typeof NativeText | typeof NativeVirtualText + typeof NativeSelectableText | typeof NativeText | typeof NativeVirtualText > declare type TextInput = typeof TextInput declare type TextInputAndroidProps = { @@ -5995,7 +5997,7 @@ export { AlertOptions, // a0cdac0f AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // ed7eb912 + Animated, // f39d3c6f AppConfig, // ebddad4b AppRegistry, // 6cdee1d6 AppState, // 12012be5 @@ -6215,7 +6217,7 @@ export { TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // e55ac2e2 + Text, // 0620c789 TextContentType, // 239b3ecc TextInput, // 2e89b91d TextInputAndroidProps, // 3f09ce49 From 2717d2b50e8cbd4fd1d42ea466dd1064daca5172 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Sun, 15 Feb 2026 20:42:08 -0800 Subject: [PATCH 2/2] Teach ReactTextView Hit Testing/a11y about Facsimile State (Fixes hit testing/a11y for selectable Text in Facsimile) Summary: When enablePreparedTextLayout is true, we may reuse layouts (including backing `Spannable`), even when ShadowNode generation changes. React tags are subject to change during this period, so we have an extra layer of indirection, that users of `PreparedLayout` based state currently have to deal with. This adds that logic so that hit testing, a11y delegate, and spans against FragmentIndex, all work in the scenario of ReactTextView with Facsimile state, used for selectable text. Changelog: [Internal] Differential Revision: D93346617 --- .../react/views/text/ReactTextView.java | 57 +++++++++++++++---- .../ReactTextViewAccessibilityDelegate.kt | 6 ++ .../react/views/text/ReactTextViewManager.kt | 4 ++ .../views/text/internal/span/ReactLinkSpan.kt | 11 +++- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 5e3cf945fdac..52f8fd58e361 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -37,6 +37,7 @@ import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.WritableMap; import com.facebook.react.common.ReactConstants; +import com.facebook.react.common.annotations.UnstableReactNativeAPI; import com.facebook.react.common.build.ReactBuildConfig; import com.facebook.react.internal.SystraceSection; import com.facebook.react.uimanager.BackgroundStyleApplicator; @@ -52,6 +53,7 @@ import com.facebook.react.uimanager.style.BorderStyle; import com.facebook.react.uimanager.style.LogicalEdge; import com.facebook.react.uimanager.style.Overflow; +import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan; import com.facebook.react.views.text.internal.span.ReactTagSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; import com.facebook.yoga.YogaMeasureMode; @@ -77,6 +79,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private Overflow mOverflow = Overflow.VISIBLE; private @Nullable Spannable mSpanned; + private @Nullable PreparedLayout mPreparedLayout; public ReactTextView(Context context) { super(context); @@ -100,6 +103,7 @@ private void initView() { mLetterSpacing = 0.f; mOverflow = Overflow.VISIBLE; mSpanned = null; + mPreparedLayout = null; } /* package */ void recycleView() { @@ -444,16 +448,33 @@ public int reactTagForTouch(float touchX, float touchY) { // if no such span can be found we will send the textview's react id as a touch handler // In case when there are more than one spans with same length we choose the last one // from the spans[] array, since it correspond to the most inner react element - ReactTagSpan[] spans = spannedText.getSpans(index, index, ReactTagSpan.class); - - if (spans != null) { - int targetSpanTextLength = text.length(); - for (int i = 0; i < spans.length; i++) { - int spanStart = spannedText.getSpanStart(spans[i]); - int spanEnd = spannedText.getSpanEnd(spans[i]); - if (spanEnd >= index && (spanEnd - spanStart) <= targetSpanTextLength) { - target = spans[i].getReactTag(); - targetSpanTextLength = (spanEnd - spanStart); + if (mPreparedLayout != null) { + ReactFragmentIndexSpan[] fragmentSpans = + spannedText.getSpans(index, index, ReactFragmentIndexSpan.class); + + if (fragmentSpans != null) { + int targetSpanTextLength = text.length(); + for (int i = 0; i < fragmentSpans.length; i++) { + int spanStart = spannedText.getSpanStart(fragmentSpans[i]); + int spanEnd = spannedText.getSpanEnd(fragmentSpans[i]); + if (spanEnd >= index && (spanEnd - spanStart) <= targetSpanTextLength) { + target = mPreparedLayout.getReactTags()[fragmentSpans[i].getFragmentIndex()]; + targetSpanTextLength = (spanEnd - spanStart); + } + } + } + } else { + ReactTagSpan[] spans = spannedText.getSpans(index, index, ReactTagSpan.class); + + if (spans != null) { + int targetSpanTextLength = text.length(); + for (int i = 0; i < spans.length; i++) { + int spanStart = spannedText.getSpanStart(spans[i]); + int spanEnd = spannedText.getSpanEnd(spans[i]); + if (spanEnd >= index && (spanEnd - spanStart) <= targetSpanTextLength) { + target = spans[i].getReactTag(); + targetSpanTextLength = (spanEnd - spanStart); + } } } } @@ -623,6 +644,22 @@ public void setSpanned(Spannable spanned) { return mSpanned; } + /** + * Get the PreparedLayout originally generated by the Fabric renderer, if using {@code + * enablePreparedTextLayout()} + * + *

TODO: Should be made internal when ReactTextView is converted to Kotlin + */ + @UnstableReactNativeAPI + @Nullable + public PreparedLayout getPreparedLayout() { + return mPreparedLayout; + } + + /* package */ void setPreparedLayout(@Nullable PreparedLayout preparedLayout) { + mPreparedLayout = preparedLayout; + } + public void setLinkifyMask(int mask) { mLinkifyMaskType = mask; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt index 7d19d6f70172..6834a329ac6c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt @@ -18,9 +18,11 @@ import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.core.view.accessibility.AccessibilityNodeProviderCompat import com.facebook.react.R +import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.ReactAccessibilityDelegate import com.facebook.react.views.text.internal.span.ReactClickableSpan +@OptIn(UnstableReactNativeAPI::class) internal class ReactTextViewAccessibilityDelegate( view: View, originalFocus: Boolean, @@ -154,6 +156,8 @@ internal class ReactTextViewAccessibilityDelegate( private fun getLayoutFromHost(): Layout? { return if (hostView is PreparedLayoutTextView) { (hostView as PreparedLayoutTextView).preparedLayout?.layout + } else if (hostView is ReactTextView && (hostView as ReactTextView).preparedLayout != null) { + (hostView as ReactTextView).preparedLayout?.layout } else if (hostView is TextView) { (hostView as TextView).layout } else { @@ -171,6 +175,8 @@ internal class ReactTextViewAccessibilityDelegate( val host = hostView return if (host is PreparedLayoutTextView) { host.preparedLayout?.layout?.text as? Spanned + } else if (host is ReactTextView && host.preparedLayout != null) { + host.preparedLayout?.layout?.text as? Spanned } else if (host is TextView) { host.text as? Spanned } else { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index e616cf5f6e75..1216d0864f7a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -167,6 +167,9 @@ public constructor( paragraphAttributes.getDouble(TextLayoutManager.PA_KEY_MINIMUM_FONT_SIZE).toFloat() view.setMinimumFontSize(minimumFontSize) + // Clear any stale PreparedLayout from a previous update + view.setPreparedLayout(null) + val textBreakStrategy = TextAttributeProps.getTextBreakStrategy( paragraphAttributes.getString(TextLayoutManager.PA_KEY_TEXT_BREAK_STRATEGY) @@ -194,6 +197,7 @@ public constructor( val text = layout.text val spanned = if (text is Spannable) text else SpannableString(text) view.setSpanned(spanned) + view.setPreparedLayout(preparedLayout) val textAlign = when (layout.alignment) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactLinkSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactLinkSpan.kt index 82cb779ac2f9..aba28b094ac7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactLinkSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactLinkSpan.kt @@ -11,8 +11,10 @@ import android.text.TextPaint import android.text.style.ClickableSpan import android.view.View import com.facebook.react.bridge.ReactContext +import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.text.PreparedLayoutTextView +import com.facebook.react.views.text.ReactTextView import com.facebook.react.views.text.TextLayoutManager import com.facebook.react.views.view.ViewGroupClickEvent @@ -29,11 +31,16 @@ import com.facebook.react.views.view.ViewGroupClickEvent * * ``` */ +@OptIn(UnstableReactNativeAPI::class) internal class ReactLinkSpan(val fragmentIndex: Int) : ClickableSpan(), ReactSpan { override fun onClick(view: View) { val context = view.context as ReactContext - val textView = view as? PreparedLayoutTextView ?: return - val preparedLayout = textView.preparedLayout ?: return + val preparedLayout = + when (view) { + is PreparedLayoutTextView -> view.preparedLayout + is ReactTextView -> view.preparedLayout + else -> null + } ?: return val reactTag = preparedLayout.reactTags[fragmentIndex] val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, reactTag) eventDispatcher?.dispatchEvent(