From face14d7a5f8bc4b8ae910b729f288e7e219adf6 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:36:25 +0530 Subject: [PATCH 01/10] feat: implement selectable text view and update text layout manager for consistent paragraph rendering --- .../Text/RCTParagraphComponentView.mm | 167 +++++++++++------- .../textlayoutmanager/RCTTextLayoutManager.h | 10 ++ .../textlayoutmanager/RCTTextLayoutManager.mm | 9 + 3 files changed, 124 insertions(+), 62 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 228ec0d2a28f..e36b288fe792 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -46,22 +46,60 @@ @interface RCTParagraphTextView : UIView @end #if !TARGET_OS_TV -@interface RCTParagraphComponentView () +/* + * A non-editable `UITextView` used to render a selectable paragraph. + * + * It is created with the very `NSTextContainer` that `RCTTextLayoutManager` + * measured the paragraph with, so its layout matches the measurement by + * construction rather than by coincidence. UIKit performs the selection; it + * never performs the layout. + */ +@interface RCTSelectableTextView : UITextView +@end -@property (nonatomic, nullable) UIEditMenuInteraction *editMenuInteraction API_AVAILABLE(ios(16.0)); +@implementation RCTSelectableTextView + +- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer +{ + if (self = [super initWithFrame:frame textContainer:textContainer]) { + self.backgroundColor = UIColor.clearColor; + self.editable = NO; + self.selectable = YES; + self.scrollEnabled = NO; + self.contentInset = UIEdgeInsetsZero; + self.textContainerInset = UIEdgeInsetsZero; + self.adjustsFontForContentSizeCategory = NO; + // `RCTTextLayoutManager` already applies the padding it wants. + self.textContainer.lineFragmentPadding = 0.0; + // The paragraph owns its layout; the text view must never reflow it. + self.textContainer.widthTracksTextView = NO; + self.textContainer.heightTracksTextView = NO; + // publishes its own accessibility elements, one per link, through + // `RCTParagraphComponentAccessibilityProvider`. Keeping the text view out of + // the accessibility tree leaves that contract exactly as it was. + self.accessibilityElementsHidden = YES; + } + return self; +} @end -#else +#endif // !TARGET_OS_TV + @interface RCTParagraphComponentView () @end -#endif @implementation RCTParagraphComponentView { ParagraphAttributes _paragraphAttributes; RCTParagraphComponentAccessibilityProvider *_accessibilityProvider; - UILongPressGestureRecognizer *_longPressGestureRecognizer; RCTParagraphTextView *_textView; CGRect _textLayoutFrame; +#if !TARGET_OS_TV + // Selection state. `_selectableTextView` is non-nil only while `selectable` is set. + RCTSelectableTextView *_selectableTextView; + RCTTextLayoutManager *_selectionLayoutManager; + NSAttributedString *_selectionRenderedText; + CGSize _selectionRenderedSize; +#endif } - (instancetype)initWithFrame:(CGRect)frame @@ -137,6 +175,9 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & { _textView.state = std::static_pointer_cast(state); [_textView setNeedsDisplay]; +#if !TARGET_OS_TV + _selectionRenderedText = nil; +#endif [self setNeedsLayout]; // If the attributed string has changed, we need to notify the accessibility system that something changed, @@ -168,6 +209,9 @@ - (void)prepareForRecycle [super prepareForRecycle]; _textView.state = nullptr; _accessibilityProvider = nil; +#if !TARGET_OS_TV + [self disableContextMenu]; +#endif } - (void)layoutSubviews @@ -196,6 +240,13 @@ - (void)layoutSubviews _textLayoutFrame = drawingFrame; _textView.frame = textViewFrame; _textView.drawingFrame = CGRectOffset(drawingFrame, -textViewFrame.origin.x, -textViewFrame.origin.y); + +#if !TARGET_OS_TV + const auto ¶graphProps = static_cast(*_props); + if (paragraphProps.isSelectable) { + [self updateSelectableTextViewWithFrame:RCTCGRectFromRect(_layoutMetrics.getContentFrame())]; + } +#endif } #pragma mark - Accessibility @@ -332,83 +383,75 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point #pragma mark - Context Menu #if !TARGET_OS_TV +/* + * Selection is provided by a `UITextView` laid out with the paragraph's own + * TextKit stack, which gives the platform behaviour users expect: long press to + * select a word, drag handles to extend the range and an edit menu that copies + * only what is selected. + */ - (void)enableContextMenu { - _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPress:)]; - - if (@available(iOS 16.0, *)) { - _editMenuInteraction = [[UIEditMenuInteraction alloc] initWithDelegate:self]; - [self addInteraction:_editMenuInteraction]; + if (_selectionLayoutManager == nil) { + _selectionLayoutManager = [RCTTextLayoutManager new]; } - [self addGestureRecognizer:_longPressGestureRecognizer]; + _selectionRenderedText = nil; + [self setNeedsLayout]; } - (void)disableContextMenu { - [self removeGestureRecognizer:_longPressGestureRecognizer]; - if (@available(iOS 16.0, *)) { - [self removeInteraction:_editMenuInteraction]; - _editMenuInteraction = nil; - } - _longPressGestureRecognizer = nil; + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + _selectionRenderedText = nil; + _selectionRenderedSize = CGSizeZero; + _textView.hidden = NO; } -- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture +/* + * Builds or repositions the selectable text view. A `UITextView` binds its text + * container at initialisation, so it is rebuilt only when the text or the + * available size actually changes. + */ +- (void)updateSelectableTextViewWithFrame:(CGRect)contentFrame { - if (@available(iOS 16.0, macCatalyst 16.0, *)) { - CGPoint location = [gesture locationInView:self]; - UIEditMenuConfiguration *config = [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:location]; - if (_editMenuInteraction) { - [_editMenuInteraction presentEditMenuWithConfiguration:config]; - } - } else { - UIMenuController *menuController = [UIMenuController sharedMenuController]; - - if (menuController.isMenuVisible) { - return; - } - - [menuController showMenuFromView:self rect:self.bounds]; + NSAttributedString *attributedText = self.attributedText; + if (attributedText == nil || CGRectIsEmpty(contentFrame)) { + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + _selectionRenderedText = nil; + _textView.hidden = NO; + return; } -} -- (BOOL)canBecomeFirstResponder -{ - const auto ¶graphProps = static_cast(*_props); - return paragraphProps.isSelectable; -} + BOOL needsRebuild = _selectableTextView == nil || + ![attributedText isEqualToAttributedString:_selectionRenderedText] || + !CGSizeEqualToSize(contentFrame.size, _selectionRenderedSize); -- (BOOL)canPerformAction:(SEL)action withSender:(id)sender -{ - const auto ¶graphProps = static_cast(*_props); + if (needsRebuild) { + NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:attributedText + paragraphAttributes:_paragraphAttributes + size:contentFrame.size]; + NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject; - if (paragraphProps.isSelectable && action == @selector(copy:)) { - return YES; + [_selectableTextView removeFromSuperview]; + _selectableTextView = [[RCTSelectableTextView alloc] initWithFrame:contentFrame textContainer:textContainer]; + [self addSubview:_selectableTextView]; + + _selectionRenderedText = [attributedText copy]; + _selectionRenderedSize = contentFrame.size; } - return [self.nextResponder canPerformAction:action withSender:sender]; + _selectableTextView.frame = contentFrame; + // The text view renders the paragraph, so the drawn copy must stay hidden. + _textView.hidden = YES; } -- (void)copy:(id)sender +- (BOOL)canBecomeFirstResponder { - NSAttributedString *attributedText = self.attributedText; - - NSMutableDictionary *item = [NSMutableDictionary new]; - - NSData *rtf = [attributedText dataFromRange:NSMakeRange(0, attributedText.length) - documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType} - error:nil]; - - if (rtf) { - [item setObject:rtf forKey:(id)kUTTypeFlatRTFD]; - } - - [item setObject:attributedText.string forKey:(id)kUTTypeUTF8PlainText]; - - UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; - pasteboard.items = @[ item ]; + // While selectable, `_selectableTextView` is the responder that owns the selection. + return NO; } + #else - (void)enableContextMenu { diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h index a4b9be72c453..9c0136e6a0cb 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h @@ -53,6 +53,16 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame atPoint:(CGPoint)point; +/** + * Returns a TextKit stack (an `NSTextStorage` with an attached `NSLayoutManager` + * and `NSTextContainer`) configured exactly as the one used to measure and draw + * the paragraph. Handing the returned container to a `UITextView` makes that + * text view lay the text out identically to `drawAttributedString:`. + */ +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + - (void)getRectWithAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes enumerateAttribute:(NSString *)enumerateAttribute diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 5f9847a398fa..5724744c7486 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -415,6 +415,15 @@ - (LinesMeasurements)getLinesForAttributedString:(facebook::react::AttributedStr return paragraphLines; } +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size +{ + return [self _textStorageAndLayoutManagerWithAttributesString:attributedString + paragraphAttributes:paragraphAttributes + size:size]; +} + - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes size:(CGSize)size From bfb22e789388df6926823b1815bb9e4f5ecf0779 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:08:23 +0530 Subject: [PATCH 02/10] fix: add textStorageForNSAttributedString method to RCTTextLayoutManager --- .../ComponentViews/Text/RCTParagraphComponentView.mm | 4 ++++ .../renderer/textlayoutmanager/RCTTextLayoutManager.h | 10 ---------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index e36b288fe792..4afc025358bc 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -32,6 +32,10 @@ - (CGRect)drawingFrameForAttributedString:(facebook::react::AttributedString)att frame:(CGRect)frame containerFrame:(CGRect *)containerFrame; +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + @end // ParagraphTextView is an auxiliary view we set as contentView so the drawing diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h index 9c0136e6a0cb..a4b9be72c453 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h @@ -53,16 +53,6 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame atPoint:(CGPoint)point; -/** - * Returns a TextKit stack (an `NSTextStorage` with an attached `NSLayoutManager` - * and `NSTextContainer`) configured exactly as the one used to measure and draw - * the paragraph. Handing the returned container to a `UITextView` makes that - * text view lay the text out identically to `drawAttributedString:`. - */ -- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString - paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes - size:(CGSize)size; - - (void)getRectWithAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes enumerateAttribute:(NSString *)enumerateAttribute From d185a90d61edf989bcc22731619ac9dd31cf0c2d Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:20:27 +0530 Subject: [PATCH 03/10] fix: enhance RCTSelectableTextView for improved text selection handling --- .../Text/RCTParagraphComponentView.mm | 163 +++++++++++++++--- 1 file changed, 142 insertions(+), 21 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 4afc025358bc..595d6821ecd9 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -51,17 +51,46 @@ @interface RCTParagraphTextView : UIView #if !TARGET_OS_TV /* - * A non-editable `UITextView` used to render a selectable paragraph. + * Strips every attribute that paints, and keeps every attribute that lays out. + * + * `RCTTextLayoutManager` draws the paragraph itself, and it draws effects UIKit + * knows nothing about: wavy, dotted and dashed decorations, and the pressed + * highlight of a nested pressable . The selection text view must lay the + * same glyphs out, because that is what places the selection rects, but it must + * not paint them. So the font, the kerning, the paragraph style and the + * attachments stay, and the colors, the decorations and the shadow go. + */ +static NSAttributedString *RCTUnpaintedAttributedString(NSAttributedString *attributedString) +{ + NSMutableAttributedString *unpainted = [attributedString mutableCopy]; + NSRange range = NSMakeRange(0, unpainted.length); + + [unpainted beginEditing]; + [unpainted addAttribute:NSForegroundColorAttributeName value:UIColor.clearColor range:range]; + [unpainted addAttribute:NSBackgroundColorAttributeName value:UIColor.clearColor range:range]; + [unpainted removeAttribute:NSUnderlineStyleAttributeName range:range]; + [unpainted removeAttribute:NSStrikethroughStyleAttributeName range:range]; + [unpainted removeAttribute:NSShadowAttributeName range:range]; + [unpainted endEditing]; + + return unpainted; +} + +/* + * A non-editable `UITextView` that provides selection for a paragraph, and + * nothing else. * * It is created with the very `NSTextContainer` that `RCTTextLayoutManager` * measured the paragraph with, so its layout matches the measurement by * construction rather than by coincidence. UIKit performs the selection; it - * never performs the layout. + * never performs the layout, and it never paints the text. */ @interface RCTSelectableTextView : UITextView @end -@implementation RCTSelectableTextView +@implementation RCTSelectableTextView { + UITapGestureRecognizer *_dismissSelectionRecognizer; +} - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer { @@ -86,6 +115,87 @@ - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)tex return self; } +#pragma mark - Dismissing the selection + +/* + * A tap outside the text clears the selection, which is what Android does and + * what a user expects. Nothing else in React Native takes first responder on a + * tap, so without this the selection stays on screen forever. + */ +- (BOOL)becomeFirstResponder +{ + BOOL didBecomeFirstResponder = [super becomeFirstResponder]; + if (didBecomeFirstResponder) { + [self _addDismissSelectionRecognizer]; + } + return didBecomeFirstResponder; +} + +- (BOOL)resignFirstResponder +{ + BOOL didResignFirstResponder = [super resignFirstResponder]; + if (didResignFirstResponder) { + [self _removeDismissSelectionRecognizer]; + self.selectedRange = NSMakeRange(0, 0); + } + return didResignFirstResponder; +} + +- (void)willMoveToWindow:(UIWindow *)newWindow +{ + [super willMoveToWindow:newWindow]; + if (newWindow == nil) { + // The recognizer holds this view, so it has to go when the view does. + [self _removeDismissSelectionRecognizer]; + } +} + +- (void)_addDismissSelectionRecognizer +{ + if (_dismissSelectionRecognizer != nil) { + return; + } + + // The recognizer belongs on the topmost React Native view, and not on the + // window: `RCTSurfaceTouchHandler` gives way to a recognizer that sits + // outside the surface, so a recognizer on the window would make every touch + // in the application wait for this one. + UIView *rootView = nil; + for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { + if ([ancestor isKindOfClass:[RCTViewComponentView class]]) { + rootView = ancestor; + } + } + if (rootView == nil) { + return; + } + + _dismissSelectionRecognizer = + [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTapToDismissSelection:)]; + // The tap still reaches the component the user tapped. + _dismissSelectionRecognizer.cancelsTouchesInView = NO; + _dismissSelectionRecognizer.delaysTouchesBegan = NO; + _dismissSelectionRecognizer.delaysTouchesEnded = NO; + [rootView addGestureRecognizer:_dismissSelectionRecognizer]; +} + +- (void)_removeDismissSelectionRecognizer +{ + [_dismissSelectionRecognizer.view removeGestureRecognizer:_dismissSelectionRecognizer]; + _dismissSelectionRecognizer = nil; +} + +- (void)_handleTapToDismissSelection:(UITapGestureRecognizer *)recognizer +{ + // A tap on the text itself belongs to the text view, which moves or clears + // the selection on its own. + if ([self pointInside:[recognizer locationInView:self] withEvent:nil]) { + return; + } + + [self resignFirstResponder]; +} + @end #endif // !TARGET_OS_TV @@ -248,7 +358,10 @@ - (void)layoutSubviews #if !TARGET_OS_TV const auto ¶graphProps = static_cast(*_props); if (paragraphProps.isSelectable) { - [self updateSelectableTextViewWithFrame:RCTCGRectFromRect(_layoutMetrics.getContentFrame())]; + // `drawingFrame` is the frame `RCTParagraphTextView` draws the glyphs into, + // compression adjustment included. The selection must use the same frame, + // or the selection rects sit away from the glyphs they select. + [self updateSelectableTextViewWithDrawingFrame:drawingFrame]; } #endif } @@ -403,12 +516,16 @@ - (void)enableContextMenu } - (void)disableContextMenu +{ + [self removeSelectableTextView]; +} + +- (void)removeSelectableTextView { [_selectableTextView removeFromSuperview]; _selectableTextView = nil; _selectionRenderedText = nil; _selectionRenderedSize = CGSizeZero; - _textView.hidden = NO; } /* @@ -416,38 +533,42 @@ - (void)disableContextMenu * container at initialisation, so it is rebuilt only when the text or the * available size actually changes. */ -- (void)updateSelectableTextViewWithFrame:(CGRect)contentFrame +- (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame { NSAttributedString *attributedText = self.attributedText; - if (attributedText == nil || CGRectIsEmpty(contentFrame)) { - [_selectableTextView removeFromSuperview]; - _selectableTextView = nil; - _selectionRenderedText = nil; - _textView.hidden = NO; + if (attributedText.length == 0 || CGRectIsEmpty(drawingFrame)) { + [self removeSelectableTextView]; return; } BOOL needsRebuild = _selectableTextView == nil || ![attributedText isEqualToAttributedString:_selectionRenderedText] || - !CGSizeEqualToSize(contentFrame.size, _selectionRenderedSize); + !CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize); if (needsRebuild) { - NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:attributedText - paragraphAttributes:_paragraphAttributes - size:contentFrame.size]; + NSTextStorage *textStorage = + [_selectionLayoutManager textStorageForNSAttributedString:RCTUnpaintedAttributedString(attributedText) + paragraphAttributes:_paragraphAttributes + size:drawingFrame.size]; NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject; [_selectableTextView removeFromSuperview]; - _selectableTextView = [[RCTSelectableTextView alloc] initWithFrame:contentFrame textContainer:textContainer]; - [self addSubview:_selectableTextView]; + _selectableTextView = [[RCTSelectableTextView alloc] initWithFrame:drawingFrame textContainer:textContainer]; + // Under the drawn paragraph, which is how a native text view stacks the two: + // UIKit paints the selection, and the glyphs go on top of it. The drawn + // paragraph passes touches through, so the text view still gets them. + UIView *container = _textView.superview; + if (container != nil) { + [container insertSubview:_selectableTextView belowSubview:_textView]; + } else { + [self addSubview:_selectableTextView]; + } _selectionRenderedText = [attributedText copy]; - _selectionRenderedSize = contentFrame.size; + _selectionRenderedSize = drawingFrame.size; } - _selectableTextView.frame = contentFrame; - // The text view renders the paragraph, so the drawn copy must stay hidden. - _textView.hidden = YES; + _selectableTextView.frame = drawingFrame; } - (BOOL)canBecomeFirstResponder From 2dbe5b784aec8ae8b9fa5be2ebef6c9990ce3248 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:11:29 +0530 Subject: [PATCH 04/10] fix: add sourceAttributedText property and update copy method in RCTSelectableTextView for improved text selection handling --- .../Text/RCTParagraphComponentView.mm | 50 +++ .../Tests/Text/RCTParagraphSelectionTests.mm | 310 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 595d6821ecd9..98e82e2f55ad 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -86,6 +86,16 @@ @interface RCTParagraphTextView : UIView * never performs the layout, and it never paints the text. */ @interface RCTSelectableTextView : UITextView + +/* + * The paragraph as it is painted, before `RCTUnpaintedAttributedString` strips + * it. The text view lays the stripped copy out, so `copy:` must read the range + * from this string instead, or the pasteboard receives clear text with no + * decorations. Both strings hold the same characters, so the range maps + * directly from one to the other. + */ +@property (nonatomic, copy, nullable) NSAttributedString *sourceAttributedText; + @end @implementation RCTSelectableTextView { @@ -196,6 +206,41 @@ - (void)_handleTapToDismissSelection:(UITapGestureRecognizer *)recognizer [self resignFirstResponder]; } +#pragma mark - Copying + +/* + * Writes the selected range to the pasteboard as rich text and as plain text, + * which is what `RCTParagraphComponentView` did for the whole paragraph before + * selection existed. `UITextView` would otherwise copy from its own storage, + * and that storage carries no colour and no decorations. + */ +- (void)copy:(id)sender +{ + NSRange selectedRange = self.selectedRange; + NSAttributedString *sourceAttributedText = _sourceAttributedText; + + if (sourceAttributedText == nil || selectedRange.length == 0 || + NSMaxRange(selectedRange) > sourceAttributedText.length) { + [super copy:sender]; + return; + } + + NSAttributedString *selectedText = [sourceAttributedText attributedSubstringFromRange:selectedRange]; + NSMutableDictionary *item = [NSMutableDictionary new]; + + NSData *rtf = [selectedText dataFromRange:NSMakeRange(0, selectedText.length) + documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType} + error:nil]; + + if (rtf) { + [item setObject:rtf forKey:(id)kUTTypeFlatRTFD]; + } + + [item setObject:selectedText.string forKey:(id)kUTTypeUTF8PlainText]; + + UIPasteboard.generalPasteboard.items = @[ item ]; +} + @end #endif // !TARGET_OS_TV @@ -518,6 +563,8 @@ - (void)enableContextMenu - (void)disableContextMenu { [self removeSelectableTextView]; + // Nothing else uses it while the paragraph is not selectable. + _selectionLayoutManager = nil; } - (void)removeSelectableTextView @@ -569,6 +616,9 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame } _selectableTextView.frame = drawingFrame; + // Copy reads the range from the painted string, not from the stripped copy + // the text view lays out. + _selectableTextView.sourceAttributedText = attributedText; } - (BOOL)canBecomeFirstResponder diff --git a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm new file mode 100644 index 000000000000..9e6c6a0013ee --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -0,0 +1,310 @@ +/* + * 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. + */ + +#import +#import + +#import + +#import +#import +#import +#import +#import +#import +#import + +using namespace facebook::react; + +/* + * Covers `` on iOS. Selection is provided by a `UITextView` + * that lays the paragraph out but never paints it, while + * `RCTParagraphComponentView` keeps drawing the glyphs. These tests hold that + * split in place, and hold the paragraph unchanged when it is not selectable. + */ +@interface RCTParagraphSelectionTests : XCTestCase +@end + +@implementation RCTParagraphSelectionTests { + std::shared_ptr _textLayoutManager; +} + +- (void)setUp +{ + [super setUp]; + _textLayoutManager = std::make_shared(std::make_shared()); +} + +#pragma mark - Fixtures + +/* + * A paragraph that paints everything UIKit cannot: a colour, a wavy underline, + * a strikethrough and a shadow. + */ +- (AttributedString)decoratedAttributedString +{ + auto textAttributes = TextAttributes{}; + textAttributes.foregroundColor = colorFromRGBA(255, 0, 0, 255); + textAttributes.fontSize = 20; + textAttributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough; + textAttributes.textDecorationStyle = TextDecorationStyle::Wavy; + + auto fragment = AttributedString::Fragment{}; + fragment.string = "Selectable wavy decoration"; + fragment.textAttributes = textAttributes; + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +- (ParagraphShadowNode::ConcreteState::Shared)stateWithAttributedString:(AttributedString)attributedString +{ + auto stateData = ParagraphState{}; + stateData.attributedString = std::move(attributedString); + stateData.paragraphAttributes = ParagraphAttributes{}; + stateData.layoutManager = _textLayoutManager; + + return std::make_shared( + std::make_shared(std::move(stateData)), ShadowNodeFamily::Weak{}); +} + +- (Props::Shared)propsWithSelectable:(BOOL)selectable +{ + auto props = std::make_shared(); + props->isSelectable = selectable; + return props; +} + +/* + * Builds a laid-out paragraph view. `layoutIfNeeded` is what creates or removes + * the selection text view, so every test needs it. + */ +- (RCTParagraphComponentView *)paragraphViewSelectable:(BOOL)selectable +{ + RCTParagraphComponentView *view = [RCTParagraphComponentView new]; + view.frame = CGRectMake(0, 0, 320, 100); + + [view updateProps:[self propsWithSelectable:selectable] oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:[self decoratedAttributedString]] oldState:nil]; + + auto layoutMetrics = LayoutMetrics{}; + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:{}]; + + [view layoutIfNeeded]; + return view; +} + +- (UITextView *)selectionTextViewIn:(UIView *)view +{ + for (UIView *subview in view.subviews) { + if ([subview isKindOfClass:[UITextView class]]) { + return (UITextView *)subview; + } + } + return nil; +} + +#pragma mark - The paragraph keeps drawing itself + +/* + * The first version of selection hid the drawn paragraph and let the text view + * paint instead. That lost wavy decorations, compressed line heights and the + * pressed highlight of a nested pressable . + */ +- (void)testDrawnParagraphStaysVisibleWhileSelectable +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + XCTAssertNotNil(view.contentView, @"The paragraph must keep its drawing view."); + XCTAssertFalse(view.contentView.hidden, @"The drawn paragraph must stay visible, or its effects are lost."); +} + +- (void)testSelectionTextViewSitsBelowTheDrawnParagraph +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + + XCTAssertNotNil(selectionTextView, @"A selectable paragraph must have a selection text view."); + + NSUInteger selectionIndex = [view.subviews indexOfObject:selectionTextView]; + NSUInteger drawnIndex = [view.subviews indexOfObject:view.contentView]; + + XCTAssertNotEqual(selectionIndex, NSNotFound); + XCTAssertNotEqual(drawnIndex, NSNotFound); + XCTAssertLessThan(selectionIndex, drawnIndex, @"UIKit paints the selection, and the glyphs must go on top of it."); +} + +/* + * The text view must lay the glyphs out, because that is what places the + * selection rects, and must paint none of them. + */ +- (void)testSelectionTextViewLaysOutButDoesNotPaint +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + NSAttributedString *laidOut = selectionTextView.textStorage; + XCTAssertGreaterThan(laidOut.length, 0u); + + NSRange range = NSMakeRange(0, laidOut.length); + [laidOut enumerateAttributesInRange:range + options:0 + usingBlock:^(NSDictionary *attributes, NSRange r, BOOL *stop) { + // Painting attributes are gone. + UIColor *foreground = attributes[NSForegroundColorAttributeName]; + XCTAssertEqualObjects(foreground, UIColor.clearColor, @"Text must not be painted twice."); + XCTAssertNil(attributes[NSUnderlineStyleAttributeName]); + XCTAssertNil(attributes[NSStrikethroughStyleAttributeName]); + XCTAssertNil(attributes[NSShadowAttributeName]); + + // Layout attributes stay, or the selection rects move. + XCTAssertNotNil(attributes[NSFontAttributeName], @"The font places the glyphs."); + }]; +} + +/* + * `copy:` maps the selected range onto the painted string. That only works if + * stripping the paint leaves the characters alone. + */ +- (void)testStrippedTextKeepsTheSameCharacters +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + XCTAssertEqualObjects( + selectionTextView.textStorage.string, + view.attributedText.string, + @"Stripping the paint must not change a single character."); +} + +#pragma mark - Copying + +/* + * Before selection existed, `copy:` wrote the whole paragraph as rich text and + * as plain text. It must still write rich text, and the rich text must carry + * the colour the user sees rather than the clear colour used for layout. + */ +- (void)testCopyWritesPaintedRichTextForTheSelectedRange +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + UIPasteboard *pasteboard = UIPasteboard.generalPasteboard; + pasteboard.items = @[]; + + NSRange selectedRange = NSMakeRange(0, 10); // "Selectable" + selectionTextView.selectedRange = selectedRange; + [selectionTextView copy:nil]; + + NSString *expected = [view.attributedText.string substringWithRange:selectedRange]; + XCTAssertEqualObjects(pasteboard.string, expected, @"Copy must copy the selected range, not the whole paragraph."); + + NSData *rtf = [pasteboard dataForPasteboardType:(id)kUTTypeFlatRTFD]; + XCTAssertNotNil(rtf, @"Copy must still put rich text on the pasteboard."); + + NSAttributedString *pasted = [[NSAttributedString alloc] initWithData:rtf + options:@{} + documentAttributes:nil + error:nil]; + XCTAssertEqualObjects(pasted.string, expected); + + // The decisive check. The text view lays out a copy with the foreground set to + // the clear colour, so a paste that came from the text view's own storage + // would be invisible. The pasted colour must be the painted one. + NSAttributedString *paintedSelection = [view.attributedText attributedSubstringFromRange:selectedRange]; + UIColor *paintedColor = [paintedSelection attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL]; + UIColor *pastedColor = [pasted attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL]; + XCTAssertNotNil(paintedColor); + XCTAssertNotNil(pastedColor); + + CGFloat paintedRed = 0, paintedAlpha = 0, pastedRed = 0, pastedAlpha = 0; + [paintedColor getRed:&paintedRed green:NULL blue:NULL alpha:&paintedAlpha]; + [pastedColor getRed:&pastedRed green:NULL blue:NULL alpha:&pastedAlpha]; + + XCTAssertEqualWithAccuracy(pastedRed, paintedRed, 0.01, @"Copied text must carry the colour the reader sees."); + XCTAssertEqualWithAccuracy(pastedAlpha, paintedAlpha, 0.01, @"Copied text must not be the clear layout copy."); + XCTAssertGreaterThan(pastedAlpha, 0.0, @"Copied text must be visible when pasted."); +} + +#pragma mark - Paragraphs that are not selectable + +/* + * Everything in this change sits behind `isSelectable`. A paragraph without it + * must be exactly what it was. + */ +- (void)testNonSelectableParagraphHasNoSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:NO]; + + XCTAssertNil([self selectionTextViewIn:view], @"A paragraph that is not selectable must gain no extra view."); + XCTAssertFalse(view.contentView.hidden); +} + +- (void)testTurningSelectableOffRemovesTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNotNil([self selectionTextViewIn:view]); + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view layoutIfNeeded]; + + XCTAssertNil([self selectionTextViewIn:view], @"Turning selection off must remove the text view."); + XCTAssertFalse(view.contentView.hidden, @"The paragraph must still draw itself."); +} + +- (void)testTurningSelectableBackOnRestoresTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view layoutIfNeeded]; + XCTAssertNil([self selectionTextViewIn:view]); + + [view updateProps:[self propsWithSelectable:YES] oldProps:[self propsWithSelectable:NO]]; + [view layoutIfNeeded]; + + XCTAssertNotNil([self selectionTextViewIn:view], @"Turning selection on again must rebuild the text view."); +} + +#pragma mark - Recycling + +/* + * Views are pooled and reused. A recycled paragraph must not carry the previous + * paragraph's selection into its next life. + */ +- (void)testRecyclingRemovesTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNotNil([self selectionTextViewIn:view]); + + [view prepareForRecycle]; + + XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view."); +} + +#pragma mark - Accessibility + +/* + * publishes one accessibility element per link through + * `RCTParagraphComponentAccessibilityProvider`. The selection text view must + * stay out of that tree, or VoiceOver reads the paragraph twice. + */ +- (void)testSelectionTextViewIsHiddenFromAccessibility +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + + XCTAssertNotNil(selectionTextView); + XCTAssertTrue(selectionTextView.accessibilityElementsHidden, @"The text view must not be read by VoiceOver."); +} + +@end From 061745c6a2e6f8198483ce687afa42c190301b2b Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:51:17 +0530 Subject: [PATCH 05/10] remove redundant comments in RCTParagraphComponentView.mm for clarity --- .../ComponentViews/Text/RCTParagraphComponentView.mm | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 98e82e2f55ad..8ca367deae2e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -182,7 +182,6 @@ - (void)_addDismissSelectionRecognizer _dismissSelectionRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTapToDismissSelection:)]; - // The tap still reaches the component the user tapped. _dismissSelectionRecognizer.cancelsTouchesInView = NO; _dismissSelectionRecognizer.delaysTouchesBegan = NO; _dismissSelectionRecognizer.delaysTouchesEnded = NO; @@ -253,7 +252,6 @@ @implementation RCTParagraphComponentView { RCTParagraphTextView *_textView; CGRect _textLayoutFrame; #if !TARGET_OS_TV - // Selection state. `_selectableTextView` is non-nil only while `selectable` is set. RCTSelectableTextView *_selectableTextView; RCTTextLayoutManager *_selectionLayoutManager; NSAttributedString *_selectionRenderedText; @@ -563,7 +561,6 @@ - (void)enableContextMenu - (void)disableContextMenu { [self removeSelectableTextView]; - // Nothing else uses it while the paragraph is not selectable. _selectionLayoutManager = nil; } @@ -576,9 +573,8 @@ - (void)removeSelectableTextView } /* - * Builds or repositions the selectable text view. A `UITextView` binds its text - * container at initialisation, so it is rebuilt only when the text or the - * available size actually changes. + * A `UITextView` binds its text container at initialisation, so the selectable + * text view is rebuilt only when the text or the available size changes. */ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame { @@ -616,8 +612,6 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame } _selectableTextView.frame = drawingFrame; - // Copy reads the range from the painted string, not from the stripped copy - // the text view lays out. _selectableTextView.sourceAttributedText = attributedText; } From 1c80a02c653f59c3642c9686ba7a2f7ef401eb5d Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:01:45 +0530 Subject: [PATCH 06/10] fix: text selection handling in RCTParagraphComponentView and add tests for selection behavior --- .../Text/RCTParagraphComponentView.mm | 74 +++++++-- .../Tests/Text/RCTParagraphSelectionTests.mm | 140 ++++++++++++++++++ 2 files changed, 203 insertions(+), 11 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 8ca367deae2e..6a7b73400266 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -20,6 +20,8 @@ #import #import +#import + #import "RCTConversions.h" #import "RCTFabricComponentsPlugins.h" @@ -71,6 +73,7 @@ @interface RCTParagraphTextView : UIView [unpainted removeAttribute:NSUnderlineStyleAttributeName range:range]; [unpainted removeAttribute:NSStrikethroughStyleAttributeName range:range]; [unpainted removeAttribute:NSShadowAttributeName range:range]; + [unpainted removeAttribute:RCTAttributedStringIsHighlightedAttributeName range:range]; [unpainted endEditing]; return unpainted; @@ -85,7 +88,7 @@ @interface RCTParagraphTextView : UIView * construction rather than by coincidence. UIKit performs the selection; it * never performs the layout, and it never paints the text. */ -@interface RCTSelectableTextView : UITextView +@interface RCTSelectableTextView : UITextView /* * The paragraph as it is painted, before `RCTUnpaintedAttributedString` strips @@ -100,6 +103,7 @@ @interface RCTSelectableTextView : UITextView @implementation RCTSelectableTextView { UITapGestureRecognizer *_dismissSelectionRecognizer; + BOOL _didCancelTouchesForSelection; } - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer @@ -121,10 +125,47 @@ - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)tex // `RCTParagraphComponentAccessibilityProvider`. Keeping the text view out of // the accessibility tree leaves that contract exactly as it was. self.accessibilityElementsHidden = YES; + self.delegate = self; } return self; } +#pragma mark - Giving the touch to the selection + +/* + * The selection gesture and `RCTSurfaceTouchHandler` track the same touch, and + * neither cancels the other. `RCTSurfaceTouchHandler` resolves its event + * emitter when the finger lands, before any selection exists, so lifting the + * finger after a long press presses the under it. Selecting a link + * follows the link. + * + * Once a selection exists the touch belongs to the selection, so the other + * recognizer has to let go. Disabling a recognizer cancels what it tracks, + * which reaches React Native as a cancelled touch and stops the press. + */ +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + BOOL hasSelection = textView.selectedRange.length > 0; + if (hasSelection && !_didCancelTouchesForSelection) { + _didCancelTouchesForSelection = YES; + [self _cancelSurfaceTouches]; + } else if (!hasSelection) { + _didCancelTouchesForSelection = NO; + } +} + +- (void)_cancelSurfaceTouches +{ + for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { + for (UIGestureRecognizer *recognizer in ancestor.gestureRecognizers) { + if ([recognizer isKindOfClass:[RCTSurfaceTouchHandler class]] && recognizer.isEnabled) { + recognizer.enabled = NO; + recognizer.enabled = YES; + } + } + } +} + #pragma mark - Dismissing the selection /* @@ -332,9 +373,6 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & { _textView.state = std::static_pointer_cast(state); [_textView setNeedsDisplay]; -#if !TARGET_OS_TV - _selectionRenderedText = nil; -#endif [self setNeedsLayout]; // If the attributed string has changed, we need to notify the accessibility system that something changed, @@ -511,6 +549,16 @@ - (UIAccessibilityTraits)accessibilityTraits - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point { +#if !TARGET_OS_TV + // A drag of a selection handle starts on the glyphs the handle sits on, and + // that is often a pressable . Adjusting a selection must not press it. + // `resignFirstResponder` empties the range, so an empty range means no + // selection is on screen and an ordinary press goes through. + if (_selectableTextView.selectedRange.length > 0) { + return nullptr; + } +#endif + const auto &state = _textView.state; if (!state) { return _eventEmitter; @@ -584,15 +632,19 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame return; } - BOOL needsRebuild = _selectableTextView == nil || - ![attributedText isEqualToAttributedString:_selectionRenderedText] || + // The layout string decides the rebuild, and the painted string does not. A + // press on a nested pressable paints a highlight, which changes the + // painted string. A rebuild in the middle of that touch destroys the text + // view before its long press starts, so the paragraph never selects. + NSAttributedString *layoutText = RCTUnpaintedAttributedString(attributedText); + + BOOL needsRebuild = _selectableTextView == nil || ![layoutText isEqualToAttributedString:_selectionRenderedText] || !CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize); if (needsRebuild) { - NSTextStorage *textStorage = - [_selectionLayoutManager textStorageForNSAttributedString:RCTUnpaintedAttributedString(attributedText) - paragraphAttributes:_paragraphAttributes - size:drawingFrame.size]; + NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:layoutText + paragraphAttributes:_paragraphAttributes + size:drawingFrame.size]; NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject; [_selectableTextView removeFromSuperview]; @@ -607,7 +659,7 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame [self addSubview:_selectableTextView]; } - _selectionRenderedText = [attributedText copy]; + _selectionRenderedText = [layoutText copy]; _selectionRenderedSize = drawingFrame.size; } diff --git a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm index 9e6c6a0013ee..3e3a582c6dff 100644 --- a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -10,10 +10,14 @@ #import +#import #import #import #import #import +#import +#import +#import #import #import #import @@ -46,16 +50,73 @@ - (void)setUp * a strikethrough and a shadow. */ - (AttributedString)decoratedAttributedString +{ + return [self decoratedAttributedStringHighlighted:NO]; +} + +- (AttributedString)decoratedAttributedStringHighlighted:(BOOL)highlighted { auto textAttributes = TextAttributes{}; textAttributes.foregroundColor = colorFromRGBA(255, 0, 0, 255); textAttributes.fontSize = 20; textAttributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough; textAttributes.textDecorationStyle = TextDecorationStyle::Wavy; + if (highlighted) { + textAttributes.isHighlighted = true; + } + + auto fragment = AttributedString::Fragment{}; + fragment.string = "Selectable wavy decoration"; + fragment.textAttributes = textAttributes; + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +/* + * A paragraph whose single fragment carries a touch emitter, which is how a + * nested pressable reaches `touchEventEmitterAtPoint:`. + */ +- (AttributedString)attributedStringWithEventEmitter:(std::shared_ptr)eventEmitter +{ + auto textAttributes = TextAttributes{}; + textAttributes.fontSize = 20; auto fragment = AttributedString::Fragment{}; fragment.string = "Selectable wavy decoration"; fragment.textAttributes = textAttributes; + fragment.parentShadowView.eventEmitter = eventEmitter; + // The emitter only reaches the attributed string when the fragment names a + // component, which is what marks it as a nested rather than raw text. + fragment.parentShadowView.componentHandle = TextShadowNode::Handle(); + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +- (RCTParagraphComponentView *)paragraphViewWithEventEmitter:(std::shared_ptr)eventEmitter +{ + RCTParagraphComponentView *view = [RCTParagraphComponentView new]; + view.frame = CGRectMake(0, 0, 320, 100); + + [view updateProps:[self propsWithSelectable:YES] oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:[self attributedStringWithEventEmitter:eventEmitter]] oldState:nil]; + + auto layoutMetrics = LayoutMetrics{}; + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:{}]; + + [view layoutIfNeeded]; + return view; +} + +- (AttributedString)attributedStringWithText:(std::string)text +{ + auto fragment = AttributedString::Fragment{}; + fragment.string = std::move(text); + fragment.textAttributes = TextAttributes{}; auto attributedString = AttributedString{}; attributedString.appendFragment(std::move(fragment)); @@ -291,6 +352,85 @@ - (void)testRecyclingRemovesTheSelectionTextView XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view."); } +#pragma mark - A press must not rebuild the selection + +/* + * Pressing a nested pressable paints a highlight, which changes the + * painted string but not the layout. Rebuilding the text view in the middle of + * that touch destroys the long press that UIKit has already started, and the + * paragraph never selects. Only a layout change may rebuild it. + */ +- (void)testHighlightOnlyUpdateKeepsTheSameSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *before = [self selectionTextViewIn:view]; + XCTAssertNotNil(before); + + [view updateState:[self stateWithAttributedString:[self decoratedAttributedStringHighlighted:YES]] oldState:nil]; + [view layoutIfNeeded]; + + XCTAssertEqual( + [self selectionTextViewIn:view], before, @"A pressed highlight must not rebuild the selection text view."); +} + +/* + * The cache must not go too far the other way: different text lays out + * differently, so it has to produce a new text view. + */ +- (void)testTextChangeRebuildsTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *before = [self selectionTextViewIn:view]; + XCTAssertNotNil(before); + + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]] + oldState:nil]; + [view layoutIfNeeded]; + + XCTAssertNotEqual([self selectionTextViewIn:view], before, @"New text must rebuild the selection text view."); +} + +#pragma mark - A showing selection owns the touch + +/* + * A drag of a selection handle starts on the glyphs the handle sits on, which + * is often a pressable . Delivering that touch presses the link, so + * adjusting a selection follows it. While a selection shows, the paragraph + * must hand out no event emitter at all. + */ +- (void)testShowingSelectionGivesNoEventEmitter +{ + auto eventEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + selectionTextView.selectedRange = NSMakeRange(0, 5); + + XCTAssertTrue( + [(id)view touchEventEmitterAtPoint:CGPointMake(100, 10)] == nullptr, + @"A touch that lands while a selection shows must not press the text under it."); +} + +/* + * The guard must not reach an ordinary tap. With nothing selected the pressable + * fragment still gets its press. + */ +- (void)testWithoutASelectionThePressStillGoesThrough +{ + auto eventEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + selectionTextView.selectedRange = NSMakeRange(0, 0); + + XCTAssertEqual( + (const void *)[(id)view touchEventEmitterAtPoint:CGPointMake(100, 10)].get(), + (const void *)eventEmitter.get(), + @"With nothing selected, a press must still reach the pressable text."); +} + #pragma mark - Accessibility /* From 06858b5874a7a337a312ae62489c1a527b57dc12 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:39:32 +0530 Subject: [PATCH 07/10] fix: improve selection text view handling in recycled paragraphs and add corresponding test --- .../Text/RCTParagraphComponentView.mm | 6 ++--- .../Tests/Text/RCTParagraphSelectionTests.mm | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 6a7b73400266..e572a5588562 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -599,9 +599,6 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point */ - (void)enableContextMenu { - if (_selectionLayoutManager == nil) { - _selectionLayoutManager = [RCTTextLayoutManager new]; - } _selectionRenderedText = nil; [self setNeedsLayout]; } @@ -642,6 +639,9 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame !CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize); if (needsRebuild) { + if (_selectionLayoutManager == nil) { + _selectionLayoutManager = [RCTTextLayoutManager new]; + } NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:layoutText paragraphAttributes:_paragraphAttributes size:drawingFrame.size]; diff --git a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm index 3e3a582c6dff..127ce985d4d6 100644 --- a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -352,6 +352,29 @@ - (void)testRecyclingRemovesTheSelectionTextView XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view."); } +/* + * A recycled view keeps the props of its last paragraph. When the next + * paragraph is selectable too, `updateProps:` sees no change and re-enables + * nothing, so the selection text view must be able to build itself from + * scratch after `prepareForRecycle`. + */ +- (void)testRecycledParagraphBuildsAWorkingSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + [view prepareForRecycle]; + + [view updateProps:[self propsWithSelectable:YES] oldProps:[self propsWithSelectable:YES]]; + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"The next paragraph"]] oldState:nil]; + [view layoutIfNeeded]; + + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView, @"A recycled selectable paragraph must get a selection text view again."); + XCTAssertEqual( + selectionTextView.textStorage.length, + [@"The next paragraph" length], + @"The selection text view of a recycled paragraph must hold the new text."); +} + #pragma mark - A press must not rebuild the selection /* From cfff5db77650d51d6a12a832872de590990c8e50 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:11:32 +0530 Subject: [PATCH 08/10] feat: split RCTSelectableTextView to add it behind feature flag --- .../Text/RCTParagraphComponentView.mm | 236 +----------------- .../Text/RCTSelectableTextView+Internal.h | 52 ++++ .../Text/RCTSelectableTextView.mm | 216 ++++++++++++++++ 3 files changed, 269 insertions(+), 235 deletions(-) create mode 100644 packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h create mode 100644 packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index e572a5588562..5d2daa7539aa 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -7,6 +7,7 @@ #import "RCTParagraphComponentView.h" #import "RCTParagraphComponentAccessibilityProvider.h" +#import "RCTSelectableTextView+Internal.h" #import #import @@ -20,8 +21,6 @@ #import #import -#import - #import "RCTConversions.h" #import "RCTFabricComponentsPlugins.h" @@ -51,239 +50,6 @@ @interface RCTParagraphTextView : UIView @end -#if !TARGET_OS_TV -/* - * Strips every attribute that paints, and keeps every attribute that lays out. - * - * `RCTTextLayoutManager` draws the paragraph itself, and it draws effects UIKit - * knows nothing about: wavy, dotted and dashed decorations, and the pressed - * highlight of a nested pressable . The selection text view must lay the - * same glyphs out, because that is what places the selection rects, but it must - * not paint them. So the font, the kerning, the paragraph style and the - * attachments stay, and the colors, the decorations and the shadow go. - */ -static NSAttributedString *RCTUnpaintedAttributedString(NSAttributedString *attributedString) -{ - NSMutableAttributedString *unpainted = [attributedString mutableCopy]; - NSRange range = NSMakeRange(0, unpainted.length); - - [unpainted beginEditing]; - [unpainted addAttribute:NSForegroundColorAttributeName value:UIColor.clearColor range:range]; - [unpainted addAttribute:NSBackgroundColorAttributeName value:UIColor.clearColor range:range]; - [unpainted removeAttribute:NSUnderlineStyleAttributeName range:range]; - [unpainted removeAttribute:NSStrikethroughStyleAttributeName range:range]; - [unpainted removeAttribute:NSShadowAttributeName range:range]; - [unpainted removeAttribute:RCTAttributedStringIsHighlightedAttributeName range:range]; - [unpainted endEditing]; - - return unpainted; -} - -/* - * A non-editable `UITextView` that provides selection for a paragraph, and - * nothing else. - * - * It is created with the very `NSTextContainer` that `RCTTextLayoutManager` - * measured the paragraph with, so its layout matches the measurement by - * construction rather than by coincidence. UIKit performs the selection; it - * never performs the layout, and it never paints the text. - */ -@interface RCTSelectableTextView : UITextView - -/* - * The paragraph as it is painted, before `RCTUnpaintedAttributedString` strips - * it. The text view lays the stripped copy out, so `copy:` must read the range - * from this string instead, or the pasteboard receives clear text with no - * decorations. Both strings hold the same characters, so the range maps - * directly from one to the other. - */ -@property (nonatomic, copy, nullable) NSAttributedString *sourceAttributedText; - -@end - -@implementation RCTSelectableTextView { - UITapGestureRecognizer *_dismissSelectionRecognizer; - BOOL _didCancelTouchesForSelection; -} - -- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer -{ - if (self = [super initWithFrame:frame textContainer:textContainer]) { - self.backgroundColor = UIColor.clearColor; - self.editable = NO; - self.selectable = YES; - self.scrollEnabled = NO; - self.contentInset = UIEdgeInsetsZero; - self.textContainerInset = UIEdgeInsetsZero; - self.adjustsFontForContentSizeCategory = NO; - // `RCTTextLayoutManager` already applies the padding it wants. - self.textContainer.lineFragmentPadding = 0.0; - // The paragraph owns its layout; the text view must never reflow it. - self.textContainer.widthTracksTextView = NO; - self.textContainer.heightTracksTextView = NO; - // publishes its own accessibility elements, one per link, through - // `RCTParagraphComponentAccessibilityProvider`. Keeping the text view out of - // the accessibility tree leaves that contract exactly as it was. - self.accessibilityElementsHidden = YES; - self.delegate = self; - } - return self; -} - -#pragma mark - Giving the touch to the selection - -/* - * The selection gesture and `RCTSurfaceTouchHandler` track the same touch, and - * neither cancels the other. `RCTSurfaceTouchHandler` resolves its event - * emitter when the finger lands, before any selection exists, so lifting the - * finger after a long press presses the under it. Selecting a link - * follows the link. - * - * Once a selection exists the touch belongs to the selection, so the other - * recognizer has to let go. Disabling a recognizer cancels what it tracks, - * which reaches React Native as a cancelled touch and stops the press. - */ -- (void)textViewDidChangeSelection:(UITextView *)textView -{ - BOOL hasSelection = textView.selectedRange.length > 0; - if (hasSelection && !_didCancelTouchesForSelection) { - _didCancelTouchesForSelection = YES; - [self _cancelSurfaceTouches]; - } else if (!hasSelection) { - _didCancelTouchesForSelection = NO; - } -} - -- (void)_cancelSurfaceTouches -{ - for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { - for (UIGestureRecognizer *recognizer in ancestor.gestureRecognizers) { - if ([recognizer isKindOfClass:[RCTSurfaceTouchHandler class]] && recognizer.isEnabled) { - recognizer.enabled = NO; - recognizer.enabled = YES; - } - } - } -} - -#pragma mark - Dismissing the selection - -/* - * A tap outside the text clears the selection, which is what Android does and - * what a user expects. Nothing else in React Native takes first responder on a - * tap, so without this the selection stays on screen forever. - */ -- (BOOL)becomeFirstResponder -{ - BOOL didBecomeFirstResponder = [super becomeFirstResponder]; - if (didBecomeFirstResponder) { - [self _addDismissSelectionRecognizer]; - } - return didBecomeFirstResponder; -} - -- (BOOL)resignFirstResponder -{ - BOOL didResignFirstResponder = [super resignFirstResponder]; - if (didResignFirstResponder) { - [self _removeDismissSelectionRecognizer]; - self.selectedRange = NSMakeRange(0, 0); - } - return didResignFirstResponder; -} - -- (void)willMoveToWindow:(UIWindow *)newWindow -{ - [super willMoveToWindow:newWindow]; - if (newWindow == nil) { - // The recognizer holds this view, so it has to go when the view does. - [self _removeDismissSelectionRecognizer]; - } -} - -- (void)_addDismissSelectionRecognizer -{ - if (_dismissSelectionRecognizer != nil) { - return; - } - - // The recognizer belongs on the topmost React Native view, and not on the - // window: `RCTSurfaceTouchHandler` gives way to a recognizer that sits - // outside the surface, so a recognizer on the window would make every touch - // in the application wait for this one. - UIView *rootView = nil; - for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { - if ([ancestor isKindOfClass:[RCTViewComponentView class]]) { - rootView = ancestor; - } - } - if (rootView == nil) { - return; - } - - _dismissSelectionRecognizer = - [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTapToDismissSelection:)]; - _dismissSelectionRecognizer.cancelsTouchesInView = NO; - _dismissSelectionRecognizer.delaysTouchesBegan = NO; - _dismissSelectionRecognizer.delaysTouchesEnded = NO; - [rootView addGestureRecognizer:_dismissSelectionRecognizer]; -} - -- (void)_removeDismissSelectionRecognizer -{ - [_dismissSelectionRecognizer.view removeGestureRecognizer:_dismissSelectionRecognizer]; - _dismissSelectionRecognizer = nil; -} - -- (void)_handleTapToDismissSelection:(UITapGestureRecognizer *)recognizer -{ - // A tap on the text itself belongs to the text view, which moves or clears - // the selection on its own. - if ([self pointInside:[recognizer locationInView:self] withEvent:nil]) { - return; - } - - [self resignFirstResponder]; -} - -#pragma mark - Copying - -/* - * Writes the selected range to the pasteboard as rich text and as plain text, - * which is what `RCTParagraphComponentView` did for the whole paragraph before - * selection existed. `UITextView` would otherwise copy from its own storage, - * and that storage carries no colour and no decorations. - */ -- (void)copy:(id)sender -{ - NSRange selectedRange = self.selectedRange; - NSAttributedString *sourceAttributedText = _sourceAttributedText; - - if (sourceAttributedText == nil || selectedRange.length == 0 || - NSMaxRange(selectedRange) > sourceAttributedText.length) { - [super copy:sender]; - return; - } - - NSAttributedString *selectedText = [sourceAttributedText attributedSubstringFromRange:selectedRange]; - NSMutableDictionary *item = [NSMutableDictionary new]; - - NSData *rtf = [selectedText dataFromRange:NSMakeRange(0, selectedText.length) - documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType} - error:nil]; - - if (rtf) { - [item setObject:rtf forKey:(id)kUTTypeFlatRTFD]; - } - - [item setObject:selectedText.string forKey:(id)kUTTypeUTF8PlainText]; - - UIPasteboard.generalPasteboard.items = @[ item ]; -} - -@end -#endif // !TARGET_OS_TV - @interface RCTParagraphComponentView () @end diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h new file mode 100644 index 000000000000..62189cd2c48e --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h @@ -0,0 +1,52 @@ +/* + * 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. + */ + +#import + +#import + +#if !TARGET_OS_TV + +NS_ASSUME_NONNULL_BEGIN + +/* + * Strips every attribute that paints, and keeps every attribute that lays out. + * + * `RCTTextLayoutManager` draws the paragraph itself, and it draws effects UIKit + * knows nothing about: wavy, dotted and dashed decorations, and the pressed + * highlight of a nested pressable . The selection text view must lay the + * same glyphs out, because that is what places the selection rects, but it must + * not paint them. So the font, the kerning, the paragraph style and the + * attachments stay, and the colors, the decorations and the shadow go. + */ +RCT_EXTERN NSAttributedString *RCTUnpaintedAttributedString(NSAttributedString *attributedString); + +/* + * A non-editable `UITextView` that provides selection for a paragraph, and + * nothing else. + * + * It is created with the very `NSTextContainer` that `RCTTextLayoutManager` + * measured the paragraph with, so its layout matches the measurement by + * construction rather than by coincidence. UIKit performs the selection; it + * never performs the layout, and it never paints the text. + */ +@interface RCTSelectableTextView : UITextView + +/* + * The paragraph as it is painted, before `RCTUnpaintedAttributedString` strips + * it. The text view lays the stripped copy out, so `copy:` must read the range + * from this string instead, or the pasteboard receives clear text with no + * decorations. Both strings hold the same characters, so the range maps + * directly from one to the other. + */ +@property (nonatomic, copy, nullable) NSAttributedString *sourceAttributedText; + +@end + +NS_ASSUME_NONNULL_END + +#endif // !TARGET_OS_TV diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm new file mode 100644 index 000000000000..5f510b584739 --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm @@ -0,0 +1,216 @@ +/* + * 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. + */ + +#import "RCTSelectableTextView+Internal.h" + +#if !TARGET_OS_TV + +#import +#import +#import +#import + +NSAttributedString *RCTUnpaintedAttributedString(NSAttributedString *attributedString) +{ + NSMutableAttributedString *unpainted = [attributedString mutableCopy]; + NSRange range = NSMakeRange(0, unpainted.length); + + [unpainted beginEditing]; + [unpainted addAttribute:NSForegroundColorAttributeName value:UIColor.clearColor range:range]; + [unpainted addAttribute:NSBackgroundColorAttributeName value:UIColor.clearColor range:range]; + [unpainted removeAttribute:NSUnderlineStyleAttributeName range:range]; + [unpainted removeAttribute:NSStrikethroughStyleAttributeName range:range]; + [unpainted removeAttribute:NSShadowAttributeName range:range]; + [unpainted removeAttribute:RCTAttributedStringIsHighlightedAttributeName range:range]; + [unpainted endEditing]; + + return unpainted; +} + +@implementation RCTSelectableTextView { + UITapGestureRecognizer *_dismissSelectionRecognizer; + BOOL _didCancelTouchesForSelection; +} + +- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer +{ + if (self = [super initWithFrame:frame textContainer:textContainer]) { + self.backgroundColor = UIColor.clearColor; + self.editable = NO; + self.selectable = YES; + self.scrollEnabled = NO; + self.contentInset = UIEdgeInsetsZero; + self.textContainerInset = UIEdgeInsetsZero; + self.adjustsFontForContentSizeCategory = NO; + // `RCTTextLayoutManager` already applies the padding it wants. + self.textContainer.lineFragmentPadding = 0.0; + // The paragraph owns its layout; the text view must never reflow it. + self.textContainer.widthTracksTextView = NO; + self.textContainer.heightTracksTextView = NO; + // publishes its own accessibility elements, one per link, through + // `RCTParagraphComponentAccessibilityProvider`. Keeping the text view out of + // the accessibility tree leaves that contract exactly as it was. + self.accessibilityElementsHidden = YES; + self.delegate = self; + } + return self; +} + +#pragma mark - Giving the touch to the selection + +/* + * The selection gesture and `RCTSurfaceTouchHandler` track the same touch, and + * neither cancels the other. `RCTSurfaceTouchHandler` resolves its event + * emitter when the finger lands, before any selection exists, so lifting the + * finger after a long press presses the under it. Selecting a link + * follows the link. + * + * Once a selection exists the touch belongs to the selection, so the other + * recognizer has to let go. Disabling a recognizer cancels what it tracks, + * which reaches React Native as a cancelled touch and stops the press. + */ +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + BOOL hasSelection = textView.selectedRange.length > 0; + if (hasSelection && !_didCancelTouchesForSelection) { + _didCancelTouchesForSelection = YES; + [self _cancelSurfaceTouches]; + } else if (!hasSelection) { + _didCancelTouchesForSelection = NO; + } +} + +- (void)_cancelSurfaceTouches +{ + for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { + for (UIGestureRecognizer *recognizer in ancestor.gestureRecognizers) { + if ([recognizer isKindOfClass:[RCTSurfaceTouchHandler class]] && recognizer.isEnabled) { + recognizer.enabled = NO; + recognizer.enabled = YES; + } + } + } +} + +#pragma mark - Dismissing the selection + +/* + * A tap outside the text clears the selection, which is what Android does and + * what a user expects. Nothing else in React Native takes first responder on a + * tap, so without this the selection stays on screen forever. + */ +- (BOOL)becomeFirstResponder +{ + BOOL didBecomeFirstResponder = [super becomeFirstResponder]; + if (didBecomeFirstResponder) { + [self _addDismissSelectionRecognizer]; + } + return didBecomeFirstResponder; +} + +- (BOOL)resignFirstResponder +{ + BOOL didResignFirstResponder = [super resignFirstResponder]; + if (didResignFirstResponder) { + [self _removeDismissSelectionRecognizer]; + self.selectedRange = NSMakeRange(0, 0); + } + return didResignFirstResponder; +} + +- (void)willMoveToWindow:(UIWindow *)newWindow +{ + [super willMoveToWindow:newWindow]; + if (newWindow == nil) { + // The recognizer holds this view, so it has to go when the view does. + [self _removeDismissSelectionRecognizer]; + } +} + +- (void)_addDismissSelectionRecognizer +{ + if (_dismissSelectionRecognizer != nil) { + return; + } + + // The recognizer belongs on the topmost React Native view, and not on the + // window: `RCTSurfaceTouchHandler` gives way to a recognizer that sits + // outside the surface, so a recognizer on the window would make every touch + // in the application wait for this one. + UIView *rootView = nil; + for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { + if ([ancestor isKindOfClass:[RCTViewComponentView class]]) { + rootView = ancestor; + } + } + if (rootView == nil) { + return; + } + + _dismissSelectionRecognizer = + [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTapToDismissSelection:)]; + _dismissSelectionRecognizer.cancelsTouchesInView = NO; + _dismissSelectionRecognizer.delaysTouchesBegan = NO; + _dismissSelectionRecognizer.delaysTouchesEnded = NO; + [rootView addGestureRecognizer:_dismissSelectionRecognizer]; +} + +- (void)_removeDismissSelectionRecognizer +{ + [_dismissSelectionRecognizer.view removeGestureRecognizer:_dismissSelectionRecognizer]; + _dismissSelectionRecognizer = nil; +} + +- (void)_handleTapToDismissSelection:(UITapGestureRecognizer *)recognizer +{ + // A tap on the text itself belongs to the text view, which moves or clears + // the selection on its own. + if ([self pointInside:[recognizer locationInView:self] withEvent:nil]) { + return; + } + + [self resignFirstResponder]; +} + +#pragma mark - Copying + +/* + * Writes the selected range to the pasteboard as rich text and as plain text, + * which is what `RCTParagraphComponentView` did for the whole paragraph before + * selection existed. `UITextView` would otherwise copy from its own storage, + * and that storage carries no colour and no decorations. + */ +- (void)copy:(id)sender +{ + NSRange selectedRange = self.selectedRange; + NSAttributedString *sourceAttributedText = _sourceAttributedText; + + if (sourceAttributedText == nil || selectedRange.length == 0 || + NSMaxRange(selectedRange) > sourceAttributedText.length) { + [super copy:sender]; + return; + } + + NSAttributedString *selectedText = [sourceAttributedText attributedSubstringFromRange:selectedRange]; + NSMutableDictionary *item = [NSMutableDictionary new]; + + NSData *rtf = [selectedText dataFromRange:NSMakeRange(0, selectedText.length) + documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType} + error:nil]; + + if (rtf) { + [item setObject:rtf forKey:(id)kUTTypeFlatRTFD]; + } + + [item setObject:selectedText.string forKey:(id)kUTTypeUTF8PlainText]; + + UIPasteboard.generalPasteboard.items = @[ item ]; +} + +@end + +#endif // !TARGET_OS_TV From ec3503a36d70b9daef3fad5c978bb850610d6d2a Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:40:01 +0530 Subject: [PATCH 09/10] fix: build the selectable text view in finalizeUpdates after layoutSubviews was removed --- .../Text/RCTParagraphComponentView.mm | 14 ++++- .../Tests/Text/RCTParagraphSelectionTests.mm | 54 +++++++++++++++---- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index c283c288fa25..abd7f69eb97e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -63,6 +63,7 @@ @implementation RCTParagraphComponentView { RCTTextLayoutManager *_selectionLayoutManager; NSAttributedString *_selectionRenderedText; CGSize _selectionRenderedSize; + BOOL _selectableTextViewNeedsUpdate; #endif } @@ -168,6 +169,15 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask if ((updateMask & (RNComponentViewUpdateMaskState | RNComponentViewUpdateMaskLayoutMetrics)) != 0) { [self _updateTextViewFrame]; } +#if !TARGET_OS_TV + // `updateProps:` runs before the state and the layout of the same mutation, + // and a change of `selectable` alone computes no frames. So the text view is + // built here, after all of the mutation, from the drawing frame computed last. + if (_selectableTextViewNeedsUpdate) { + _selectableTextViewNeedsUpdate = NO; + [self updateSelectableTextViewWithDrawingFrame:_textLayoutFrame]; + } +#endif } - (void)prepareForRecycle @@ -368,12 +378,12 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point */ - (void)enableContextMenu { - _selectionRenderedText = nil; - [self setNeedsLayout]; + _selectableTextViewNeedsUpdate = YES; } - (void)disableContextMenu { + _selectableTextViewNeedsUpdate = NO; [self removeSelectableTextView]; _selectionLayoutManager = nil; } diff --git a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm index 127ce985d4d6..c2943ad01086 100644 --- a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -108,7 +108,7 @@ - (RCTParagraphComponentView *)paragraphViewWithEventEmitter:(std::shared_ptr Date: Sat, 10 Oct 2026 16:49:52 +0530 Subject: [PATCH 10/10] feat: put partial text selection behind the enableIOSPartialTextSelection flag --- .../Text/RCTParagraphComponentView.mm | 131 +++++++++++-- .../Tests/Text/RCTParagraphSelectionTests.mm | 183 +++++++++++++++++- .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 144 ++++++++------ .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../ReactNativeFeatureFlags.config.js | 11 ++ .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- 22 files changed, 493 insertions(+), 102 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index abd7f69eb97e..a7d5cf344307 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -50,12 +50,21 @@ @interface RCTParagraphTextView : UIView @end +#if !TARGET_OS_TV +@interface RCTParagraphComponentView () + +@property (nonatomic, nullable) UIEditMenuInteraction *editMenuInteraction API_AVAILABLE(ios(16.0)); + +@end +#else @interface RCTParagraphComponentView () @end +#endif @implementation RCTParagraphComponentView { ParagraphAttributes _paragraphAttributes; RCTParagraphComponentAccessibilityProvider *_accessibilityProvider; + UILongPressGestureRecognizer *_longPressGestureRecognizer; RCTParagraphTextView *_textView; CGRect _textLayoutFrame; #if !TARGET_OS_TV @@ -186,7 +195,12 @@ - (void)prepareForRecycle _textView.state = nullptr; _accessibilityProvider = nil; #if !TARGET_OS_TV - [self disableContextMenu]; + // Recycling removes only the selection text view. The long press stays, + // because the view keeps its `_props`: when the next paragraph is selectable + // too, `updateProps:` sees no change and does not add the long press again. + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + [self disableTextSelection]; + } #endif } @@ -217,7 +231,7 @@ - (void)_updateTextViewFrame #if !TARGET_OS_TV const auto ¶graphProps = static_cast(*_props); - if (paragraphProps.isSelectable) { + if (paragraphProps.isSelectable && ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { // `drawingFrame` is the frame `RCTParagraphTextView` draws the glyphs into, // compression adjustment included. The selection must use the same frame, // or the selection rects sit away from the glyphs they select. @@ -370,18 +384,120 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point #pragma mark - Context Menu #if !TARGET_OS_TV +- (void)enableContextMenu +{ + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + [self enableTextSelection]; + return; + } + + _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPress:)]; + + if (@available(iOS 16.0, *)) { + _editMenuInteraction = [[UIEditMenuInteraction alloc] initWithDelegate:self]; + [self addInteraction:_editMenuInteraction]; + } + [self addGestureRecognizer:_longPressGestureRecognizer]; +} + +- (void)disableContextMenu +{ + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + [self disableTextSelection]; + return; + } + + [self removeGestureRecognizer:_longPressGestureRecognizer]; + if (@available(iOS 16.0, *)) { + [self removeInteraction:_editMenuInteraction]; + _editMenuInteraction = nil; + } + _longPressGestureRecognizer = nil; +} + +- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture +{ + if (@available(iOS 16.0, macCatalyst 16.0, *)) { + CGPoint location = [gesture locationInView:self]; + UIEditMenuConfiguration *config = [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:location]; + if (_editMenuInteraction) { + [_editMenuInteraction presentEditMenuWithConfiguration:config]; + } + } else { + UIMenuController *menuController = [UIMenuController sharedMenuController]; + + if (menuController.isMenuVisible) { + return; + } + + [menuController showMenuFromView:self rect:self.bounds]; + } +} + +- (BOOL)canBecomeFirstResponder +{ + // With partial selection, `_selectableTextView` is the responder that owns the selection. + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + return NO; + } + + const auto ¶graphProps = static_cast(*_props); + return paragraphProps.isSelectable; +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + // With partial selection, `_selectableTextView` copies the selected range. + // This class implements `copy:` for the long-press menu, so the inherited + // answer for `copy:` is YES and would make the paragraph copy all its text. + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + return action != @selector(copy:) && [super canPerformAction:action withSender:sender]; + } + + const auto ¶graphProps = static_cast(*_props); + + if (paragraphProps.isSelectable && action == @selector(copy:)) { + return YES; + } + + return [self.nextResponder canPerformAction:action withSender:sender]; +} + +- (void)copy:(id)sender +{ + NSAttributedString *attributedText = self.attributedText; + + NSMutableDictionary *item = [NSMutableDictionary new]; + + NSData *rtf = [attributedText dataFromRange:NSMakeRange(0, attributedText.length) + documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType} + error:nil]; + + if (rtf) { + [item setObject:rtf forKey:(id)kUTTypeFlatRTFD]; + } + + [item setObject:attributedText.string forKey:(id)kUTTypeUTF8PlainText]; + + UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; + pasteboard.items = @[ item ]; +} + +#pragma mark - Text Selection + /* * Selection is provided by a `UITextView` laid out with the paragraph's own * TextKit stack, which gives the platform behaviour users expect: long press to * select a word, drag handles to extend the range and an edit menu that copies * only what is selected. */ -- (void)enableContextMenu +- (void)enableTextSelection { _selectableTextViewNeedsUpdate = YES; } -- (void)disableContextMenu +- (void)disableTextSelection { _selectableTextViewNeedsUpdate = NO; [self removeSelectableTextView]; @@ -445,13 +561,6 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame _selectableTextView.frame = drawingFrame; _selectableTextView.sourceAttributedText = attributedText; } - -- (BOOL)canBecomeFirstResponder -{ - // While selectable, `_selectableTextView` is the responder that owns the selection. - return NO; -} - #else - (void)enableContextMenu { diff --git a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm index c2943ad01086..032e891394fe 100644 --- a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -11,6 +11,8 @@ #import #import +#import +#import #import #import #import @@ -24,11 +26,45 @@ using namespace facebook::react; +class RCTParagraphSelectionFeatureFlags : public ReactNativeFeatureFlagsDefaults { + public: + explicit RCTParagraphSelectionFeatureFlags(bool partialTextSelection) : partialTextSelection_(partialTextSelection) {} + + bool enableIOSPartialTextSelection() override + { + return partialTextSelection_; + } + + private: + bool partialTextSelection_; +}; + +/* + * A native parent with an edit-menu action of its own. + */ +@interface RCTParagraphSelectionMenuParent : UIView + +- (void)customMenuAction:(id)sender; + +@end + +@implementation RCTParagraphSelectionMenuParent + +- (void)customMenuAction:(id)sender +{ +} + +@end + /* * Covers `` on iOS. Selection is provided by a `UITextView` * that lays the paragraph out but never paints it, while * `RCTParagraphComponentView` keeps drawing the glyphs. These tests hold that * split in place, and hold the paragraph unchanged when it is not selectable. + * + * Partial selection is behind `enableIOSPartialTextSelection`. `setUp` turns + * the flag on. The tests under "With the flag off" turn it off, and hold the + * paragraph to the long-press menu that copies the whole text. */ @interface RCTParagraphSelectionTests : XCTestCase @end @@ -40,9 +76,26 @@ @implementation RCTParagraphSelectionTests { - (void)setUp { [super setUp]; + [self setPartialTextSelectionEnabled:YES]; _textLayoutManager = std::make_shared(std::make_shared()); } +- (void)tearDown +{ + ReactNativeFeatureFlags::dangerouslyReset(); + [super tearDown]; +} + +/* + * Call this before the test creates a paragraph view. The view reads the flag + * on every change of `selectable`, on recycling and on every frame update, so + * a view that sees both values is half in each mode. + */ +- (void)setPartialTextSelectionEnabled:(BOOL)enabled +{ + ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique(enabled)); +} + #pragma mark - Fixtures /* @@ -172,6 +225,41 @@ - (UITextView *)selectionTextViewIn:(UIView *)view return nil; } +- (UILongPressGestureRecognizer *)longPressRecognizerIn:(UIView *)view +{ + for (UIGestureRecognizer *recognizer in view.gestureRecognizers) { + if ([recognizer isKindOfClass:[UILongPressGestureRecognizer class]]) { + return (UILongPressGestureRecognizer *)recognizer; + } + } + return nil; +} + +- (UIEditMenuInteraction *)editMenuInteractionIn:(UIView *)view API_AVAILABLE(ios(16.0)) +{ + for (id interaction in view.interactions) { + if ([interaction isKindOfClass:[UIEditMenuInteraction class]]) { + return (UIEditMenuInteraction *)interaction; + } + } + return nil; +} + +/* + * Mounts a recycled view again with the update calls of an insert. The view + * keeps its `_props`, and `prepareForRecycle` empties its layout metrics. + */ +- (void)mountRecycledView:(RCTParagraphComponentView *)view selectable:(BOOL)selectable text:(std::string)text +{ + [view updateProps:[self propsWithSelectable:selectable] oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:std::move(text)]] oldState:nil]; + + auto layoutMetrics = LayoutMetrics{}; + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics]; + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; +} + #pragma mark - The paragraph keeps drawing itself /* @@ -391,15 +479,7 @@ - (void)testRecycledParagraphBuildsAWorkingSelectionTextView { RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; [view prepareForRecycle]; - - [view updateProps:[self propsWithSelectable:YES] oldProps:[self propsWithSelectable:YES]]; - [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"The next paragraph"]] oldState:nil]; - - // `prepareForRecycle` empties the layout metrics, and the next insert sets them again. - auto layoutMetrics = LayoutMetrics{}; - layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; - [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics]; - [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + [self mountRecycledView:view selectable:YES text:"The next paragraph"]; UITextView *selectionTextView = [self selectionTextViewIn:view]; XCTAssertNotNil(selectionTextView, @"A recycled selectable paragraph must get a selection text view again."); @@ -504,4 +584,89 @@ - (void)testSelectionTextViewIsHiddenFromAccessibility XCTAssertTrue(selectionTextView.accessibilityElementsHidden, @"The text view must not be read by VoiceOver."); } +#pragma mark - The selection text view owns the edit menu + +/* + * The paragraph implements `copy:` for the long-press menu of the flag-off + * path. With the flag on it must refuse `copy:`, or a copy that reaches it + * copies the whole text instead of the selected range. + */ +- (void)testSelectableParagraphLeavesCopyToTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + XCTAssertFalse(view.canBecomeFirstResponder, @"The selection text view owns the selection."); + XCTAssertFalse([view canPerformAction:@selector(copy:) withSender:nil], @"Only the selection text view may copy."); + XCTAssertNil([self longPressRecognizerIn:view], @"The long-press menu of the flag-off path must not exist."); +} + +/* + * UIKit looks for the target of an edit-menu action up the responder chain, + * from the selection text view. A paragraph that answers for its parent stops + * that walk and then gets an action that it cannot perform. + */ +- (void)testSelectableParagraphLeavesOtherActionsToItsParent +{ + RCTParagraphSelectionMenuParent *parent = [RCTParagraphSelectionMenuParent new]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + [parent addSubview:view]; + + XCTAssertTrue([parent canPerformAction:@selector(customMenuAction:) withSender:nil]); + XCTAssertFalse( + [view canPerformAction:@selector(customMenuAction:) withSender:nil], + @"The paragraph must not answer for an action that only its parent performs."); +} + +#pragma mark - With the flag off + +/* + * Without the flag, a selectable paragraph is what it was before partial + * selection: no selection text view, and a long press that shows a menu to + * copy the whole text. + */ +- (void)testFlagOffSelectableParagraphKeepsTheLongPressMenu +{ + [self setPartialTextSelectionEnabled:NO]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + XCTAssertNil([self selectionTextViewIn:view], @"Without the flag, a paragraph must gain no selection text view."); + XCTAssertNotNil([self longPressRecognizerIn:view], @"Without the flag, a long press must show the menu."); + if (@available(iOS 16.0, *)) { + XCTAssertNotNil([self editMenuInteractionIn:view]); + } + XCTAssertTrue(view.canBecomeFirstResponder); + XCTAssertTrue([view canPerformAction:@selector(copy:) withSender:nil], @"The menu copies the whole text."); +} + +- (void)testFlagOffTurningSelectableOffRemovesTheLongPressMenu +{ + [self setPartialTextSelectionEnabled:NO]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + XCTAssertNil([self longPressRecognizerIn:view]); + if (@available(iOS 16.0, *)) { + XCTAssertNil([self editMenuInteractionIn:view]); + } + XCTAssertFalse(view.canBecomeFirstResponder); +} + +/* + * A recycled view keeps its props. When the next paragraph is selectable too, + * `updateProps:` sees no change and does not add the long press again, so + * recycling must not remove it. + */ +- (void)testFlagOffRecycledParagraphKeepsTheLongPressMenu +{ + [self setPartialTextSelectionEnabled:NO]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + [view prepareForRecycle]; + [self mountRecycledView:view selectable:YES text:"The next paragraph"]; + + XCTAssertNotNil([self longPressRecognizerIn:view], @"A recycled selectable paragraph must keep its long press."); + XCTAssertNil([self selectionTextViewIn:view]); +} + @end diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 07ef963a6612..cd8f9f4c6dbe 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -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<<72ad53973a7de132705b9ec1a72aac08>> + * @generated SignedSource<<6d1d028d27437df483886b87151f6cd6>> */ /** @@ -204,6 +204,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enableIOSCompressedTextFrameAdjustment(): Boolean = accessor.enableIOSCompressedTextFrameAdjustment() + /** + * Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph. + */ + @JvmStatic + public fun enableIOSPartialTextSelection(): Boolean = accessor.enableIOSPartialTextSelection() + /** * Applies base offset for each line of text separately on iOS. */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 31815488c19a..33ad87d9dd61 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -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<<60051ebbc387ff06db855ed661340217>> + * @generated SignedSource<> */ /** @@ -49,6 +49,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null private var enableFontScaleChangesUpdatingLayoutCache: Boolean? = null private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null + private var enableIOSPartialTextSelectionCache: Boolean? = null private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null private var enableIOSViewClipToPaddingBoxCache: Boolean? = null private var enableImagePrefetchingAndroidCache: Boolean? = null @@ -373,6 +374,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun enableIOSPartialTextSelection(): Boolean { + var cached = enableIOSPartialTextSelectionCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.enableIOSPartialTextSelection() + enableIOSPartialTextSelectionCache = cached + } + return cached + } + override fun enableIOSTextBaselineOffsetPerLine(): Boolean { var cached = enableIOSTextBaselineOffsetPerLineCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index 88728162c941..f63e45449d89 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -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<<9ed55c4c14d640c0fb2bd6c8fb7ef557>> + * @generated SignedSource<<60ced4c5136d47b2929752516b6ae2ea>> */ /** @@ -86,6 +86,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun enableIOSCompressedTextFrameAdjustment(): Boolean + @DoNotStrip @JvmStatic public external fun enableIOSPartialTextSelection(): Boolean + @DoNotStrip @JvmStatic public external fun enableIOSTextBaselineOffsetPerLine(): Boolean @DoNotStrip @JvmStatic public external fun enableIOSViewClipToPaddingBox(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index b80430013c55..5f2e0ef375b9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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<<2170527111c8b3c34c213c24a9948931>> + * @generated SignedSource<<1d8c2e54c4ff3c57f2e8190811dc4399>> */ /** @@ -81,6 +81,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enableIOSCompressedTextFrameAdjustment(): Boolean = false + override fun enableIOSPartialTextSelection(): Boolean = false + override fun enableIOSTextBaselineOffsetPerLine(): Boolean = false override fun enableIOSViewClipToPaddingBox(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index ddef18a5642f..8872dd673c7b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -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<> + * @generated SignedSource<> */ /** @@ -53,6 +53,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null private var enableFontScaleChangesUpdatingLayoutCache: Boolean? = null private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null + private var enableIOSPartialTextSelectionCache: Boolean? = null private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null private var enableIOSViewClipToPaddingBoxCache: Boolean? = null private var enableImagePrefetchingAndroidCache: Boolean? = null @@ -406,6 +407,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun enableIOSPartialTextSelection(): Boolean { + var cached = enableIOSPartialTextSelectionCache + if (cached == null) { + cached = currentProvider.enableIOSPartialTextSelection() + accessedFeatureFlags.add("enableIOSPartialTextSelection") + enableIOSPartialTextSelectionCache = cached + } + return cached + } + override fun enableIOSTextBaselineOffsetPerLine(): Boolean { var cached = enableIOSTextBaselineOffsetPerLineCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 399cbb4f0f91..3ea2508543c7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -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<<256b97731c59551616a8e0cbc777f3fa>> + * @generated SignedSource<<99236a6ac70d6efaf668a07185787560>> */ /** @@ -81,6 +81,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun enableIOSCompressedTextFrameAdjustment(): Boolean + @DoNotStrip public fun enableIOSPartialTextSelection(): Boolean + @DoNotStrip public fun enableIOSTextBaselineOffsetPerLine(): Boolean @DoNotStrip public fun enableIOSViewClipToPaddingBox(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index 1c7c222ebe99..83de406c3da3 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -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<<2b4f896552dd54c3ec858ca788ab4254>> + * @generated SignedSource<> */ /** @@ -213,6 +213,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool enableIOSPartialTextSelection() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableIOSPartialTextSelection"); + return method(javaProvider_); + } + bool enableIOSTextBaselineOffsetPerLine() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableIOSTextBaselineOffsetPerLine"); @@ -734,6 +740,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enableIOSCompressedTextFrameAdjustment( return ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment(); } +bool JReactNativeFeatureFlagsCxxInterop::enableIOSPartialTextSelection( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::enableIOSPartialTextSelection(); +} + bool JReactNativeFeatureFlagsCxxInterop::enableIOSTextBaselineOffsetPerLine( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine(); @@ -1162,6 +1173,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "enableIOSCompressedTextFrameAdjustment", JReactNativeFeatureFlagsCxxInterop::enableIOSCompressedTextFrameAdjustment), + makeNativeMethod( + "enableIOSPartialTextSelection", + JReactNativeFeatureFlagsCxxInterop::enableIOSPartialTextSelection), makeNativeMethod( "enableIOSTextBaselineOffsetPerLine", JReactNativeFeatureFlagsCxxInterop::enableIOSTextBaselineOffsetPerLine), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index 1cc1567097c5..2433c3835bc6 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -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<<228fff4f0ef0e530bf50203050adb9b0>> + * @generated SignedSource<> */ /** @@ -117,6 +117,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool enableIOSCompressedTextFrameAdjustment( facebook::jni::alias_ref); + static bool enableIOSPartialTextSelection( + facebook::jni::alias_ref); + static bool enableIOSTextBaselineOffsetPerLine( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index a3c89045c15e..b71e715b3c97 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -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<<2c4c3259015438d07cc137e9288db04a>> + * @generated SignedSource<> */ /** @@ -142,6 +142,10 @@ bool ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment() { return getAccessor().enableIOSCompressedTextFrameAdjustment(); } +bool ReactNativeFeatureFlags::enableIOSPartialTextSelection() { + return getAccessor().enableIOSPartialTextSelection(); +} + bool ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine() { return getAccessor().enableIOSTextBaselineOffsetPerLine(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 937527653893..f48641672e94 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -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<<73ae8b45c0961bc60eeb804d4d40bd07>> + * @generated SignedSource<<722a0f944588de1699cf96fe7c822679>> */ /** @@ -186,6 +186,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enableIOSCompressedTextFrameAdjustment(); + /** + * Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph. + */ + RN_EXPORT static bool enableIOSPartialTextSelection(); + /** * Applies base offset for each line of text separately on iOS. */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 0f3cf823a5a1..c7e5dbe1d095 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -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<<46fc995d3bb226c417f9c6f8f8089360>> + * @generated SignedSource<<96517a6218c8a941f0d4ade165844e7c>> */ /** @@ -551,6 +551,24 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSCompressedTextFrameAdjustment() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::enableIOSPartialTextSelection() { + auto flagValue = enableIOSPartialTextSelection_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(29, "enableIOSPartialTextSelection"); + + flagValue = currentProvider_->enableIOSPartialTextSelection(); + enableIOSPartialTextSelection_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enableIOSTextBaselineOffsetPerLine() { auto flagValue = enableIOSTextBaselineOffsetPerLine_.load(); @@ -560,7 +578,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSTextBaselineOffsetPerLine() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(29, "enableIOSTextBaselineOffsetPerLine"); + markFlagAsAccessed(30, "enableIOSTextBaselineOffsetPerLine"); flagValue = currentProvider_->enableIOSTextBaselineOffsetPerLine(); enableIOSTextBaselineOffsetPerLine_ = flagValue; @@ -578,7 +596,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSViewClipToPaddingBox() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(30, "enableIOSViewClipToPaddingBox"); + markFlagAsAccessed(31, "enableIOSViewClipToPaddingBox"); flagValue = currentProvider_->enableIOSViewClipToPaddingBox(); enableIOSViewClipToPaddingBox_ = flagValue; @@ -596,7 +614,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImagePrefetchingAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(31, "enableImagePrefetchingAndroid"); + markFlagAsAccessed(32, "enableImagePrefetchingAndroid"); flagValue = currentProvider_->enableImagePrefetchingAndroid(); enableImagePrefetchingAndroid_ = flagValue; @@ -614,7 +632,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImageTransparentTintColor() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(32, "enableImageTransparentTintColor"); + markFlagAsAccessed(33, "enableImageTransparentTintColor"); flagValue = currentProvider_->enableImageTransparentTintColor(); enableImageTransparentTintColor_ = flagValue; @@ -632,7 +650,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImmediateUpdateModeForContentOffsetC // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(33, "enableImmediateUpdateModeForContentOffsetChanges"); + markFlagAsAccessed(34, "enableImmediateUpdateModeForContentOffsetChanges"); flagValue = currentProvider_->enableImmediateUpdateModeForContentOffsetChanges(); enableImmediateUpdateModeForContentOffsetChanges_ = flagValue; @@ -650,7 +668,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(34, "enableImperativeEvents"); + markFlagAsAccessed(35, "enableImperativeEvents"); flagValue = currentProvider_->enableImperativeEvents(); enableImperativeEvents_ = flagValue; @@ -668,7 +686,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeFocus() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(35, "enableImperativeFocus"); + markFlagAsAccessed(36, "enableImperativeFocus"); flagValue = currentProvider_->enableImperativeFocus(); enableImperativeFocus_ = flagValue; @@ -686,7 +704,7 @@ bool ReactNativeFeatureFlagsAccessor::enableInteropViewManagerClassLookUpOptimiz // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(36, "enableInteropViewManagerClassLookUpOptimizationIOS"); + markFlagAsAccessed(37, "enableInteropViewManagerClassLookUpOptimizationIOS"); flagValue = currentProvider_->enableInteropViewManagerClassLookUpOptimizationIOS(); enableInteropViewManagerClassLookUpOptimizationIOS_ = flagValue; @@ -704,7 +722,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIntersectionObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(37, "enableIntersectionObserverByDefault"); + markFlagAsAccessed(38, "enableIntersectionObserverByDefault"); flagValue = currentProvider_->enableIntersectionObserverByDefault(); enableIntersectionObserverByDefault_ = flagValue; @@ -722,7 +740,7 @@ bool ReactNativeFeatureFlagsAccessor::enableKeyEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(38, "enableKeyEvents"); + markFlagAsAccessed(39, "enableKeyEvents"); flagValue = currentProvider_->enableKeyEvents(); enableKeyEvents_ = flagValue; @@ -740,7 +758,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(39, "enableLayoutAnimationsOnAndroid"); + markFlagAsAccessed(40, "enableLayoutAnimationsOnAndroid"); flagValue = currentProvider_->enableLayoutAnimationsOnAndroid(); enableLayoutAnimationsOnAndroid_ = flagValue; @@ -758,7 +776,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnIOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(40, "enableLayoutAnimationsOnIOS"); + markFlagAsAccessed(41, "enableLayoutAnimationsOnIOS"); flagValue = currentProvider_->enableLayoutAnimationsOnIOS(); enableLayoutAnimationsOnIOS_ = flagValue; @@ -776,7 +794,7 @@ bool ReactNativeFeatureFlagsAccessor::enableModuleArgumentNSNullConversionIOS() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(41, "enableModuleArgumentNSNullConversionIOS"); + markFlagAsAccessed(42, "enableModuleArgumentNSNullConversionIOS"); flagValue = currentProvider_->enableModuleArgumentNSNullConversionIOS(); enableModuleArgumentNSNullConversionIOS_ = flagValue; @@ -794,7 +812,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMountingCoordinatorPullModelAndroid( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(42, "enableMountingCoordinatorPullModelAndroid"); + markFlagAsAccessed(43, "enableMountingCoordinatorPullModelAndroid"); flagValue = currentProvider_->enableMountingCoordinatorPullModelAndroid(); enableMountingCoordinatorPullModelAndroid_ = flagValue; @@ -812,7 +830,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMutationObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(43, "enableMutationObserverByDefault"); + markFlagAsAccessed(44, "enableMutationObserverByDefault"); flagValue = currentProvider_->enableMutationObserverByDefault(); enableMutationObserverByDefault_ = flagValue; @@ -830,7 +848,7 @@ bool ReactNativeFeatureFlagsAccessor::enableNativeCSSParsing() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(44, "enableNativeCSSParsing"); + markFlagAsAccessed(45, "enableNativeCSSParsing"); flagValue = currentProvider_->enableNativeCSSParsing(); enableNativeCSSParsing_ = flagValue; @@ -848,7 +866,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreallocatedPropsDiffOnInsertAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(45, "enablePreallocatedPropsDiffOnInsertAndroid"); + markFlagAsAccessed(46, "enablePreallocatedPropsDiffOnInsertAndroid"); flagValue = currentProvider_->enablePreallocatedPropsDiffOnInsertAndroid(); enablePreallocatedPropsDiffOnInsertAndroid_ = flagValue; @@ -866,7 +884,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(46, "enablePreparedTextLayout"); + markFlagAsAccessed(47, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -884,7 +902,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePropsUpdateReconciliationAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(47, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(48, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -902,7 +920,7 @@ bool ReactNativeFeatureFlagsAccessor::enableResizeObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(48, "enableResizeObserverByDefault"); + markFlagAsAccessed(49, "enableResizeObserverByDefault"); flagValue = currentProvider_->enableResizeObserverByDefault(); enableResizeObserverByDefault_ = flagValue; @@ -920,7 +938,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSwiftUIBasedFilters() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(49, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(50, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -938,7 +956,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewCulling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(50, "enableViewCulling"); + markFlagAsAccessed(51, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -956,7 +974,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecycling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(51, "enableViewRecycling"); + markFlagAsAccessed(52, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -974,7 +992,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForImage() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(52, "enableViewRecyclingForImage"); + markFlagAsAccessed(53, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -992,7 +1010,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForScrollView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(53, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(54, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -1010,7 +1028,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForText() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(54, "enableViewRecyclingForText"); + markFlagAsAccessed(55, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -1028,7 +1046,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(55, "enableViewRecyclingForView"); + markFlagAsAccessed(56, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -1046,7 +1064,7 @@ bool ReactNativeFeatureFlagsAccessor::enableVirtualViewContainerStateExperimenta // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(56, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(57, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -1064,7 +1082,7 @@ bool ReactNativeFeatureFlagsAccessor::fixBorderlessRippleAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(57, "fixBorderlessRippleAndroid"); + markFlagAsAccessed(58, "fixBorderlessRippleAndroid"); flagValue = currentProvider_->fixBorderlessRippleAndroid(); fixBorderlessRippleAndroid_ = flagValue; @@ -1082,7 +1100,7 @@ bool ReactNativeFeatureFlagsAccessor::fixMappingOfEventPrioritiesBetweenFabricAn // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(58, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(59, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1100,7 +1118,7 @@ bool ReactNativeFeatureFlagsAccessor::fixYogaFlexBasisFitContentInMainAxis() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(59, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(60, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxAssertSingleHostState() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(61, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxEnabledRelease() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "fuseboxEnabledRelease"); + markFlagAsAccessed(62, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxFrameRecordingEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(63, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(64, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1190,7 +1208,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(65, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1208,7 +1226,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(66, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(67, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "perfIssuesEnabled"); + markFlagAsAccessed(68, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "perfMonitorV2Enabled"); + markFlagAsAccessed(69, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1280,7 +1298,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "preparedTextCacheSize"); + markFlagAsAccessed(70, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(71, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "redBoxV2Android"); + markFlagAsAccessed(72, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "redBoxV2IOS"); + markFlagAsAccessed(73, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(74, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(75, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(76, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(77, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(78, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(79, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(80, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(81, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "useFabricInterop"); + markFlagAsAccessed(82, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(83, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1532,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "useNestedScrollViewAndroid"); + markFlagAsAccessed(84, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "useSharedAnimatedBackend"); + markFlagAsAccessed(85, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(86, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1586,7 +1604,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "useTurboModuleInterop"); + markFlagAsAccessed(87, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1604,7 +1622,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(87, "viewCullingOutsetRatio"); + markFlagAsAccessed(88, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1622,7 +1640,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(88, "viewTransitionEnabled"); + markFlagAsAccessed(89, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1640,7 +1658,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(89, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(90, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1658,7 +1676,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(90, "virtualViewPrerenderRatio"); + markFlagAsAccessed(91, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 5233e208552e..3cdc931e6944 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -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<> + * @generated SignedSource<<2dd1249cd98bf8d1e8d4763ef7b54b55>> */ /** @@ -63,6 +63,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableFlexboxAutoMinSizeInStrictMode(); bool enableFontScaleChangesUpdatingLayout(); bool enableIOSCompressedTextFrameAdjustment(); + bool enableIOSPartialTextSelection(); bool enableIOSTextBaselineOffsetPerLine(); bool enableIOSViewClipToPaddingBox(); bool enableImagePrefetchingAndroid(); @@ -136,7 +137,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 91> accessedFeatureFlags_; + std::array, 92> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -167,6 +168,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableFlexboxAutoMinSizeInStrictMode_; std::atomic> enableFontScaleChangesUpdatingLayout_; std::atomic> enableIOSCompressedTextFrameAdjustment_; + std::atomic> enableIOSPartialTextSelection_; std::atomic> enableIOSTextBaselineOffsetPerLine_; std::atomic> enableIOSViewClipToPaddingBox_; std::atomic> enableImagePrefetchingAndroid_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index 18e5f25979bc..1aa02a1fd2fa 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<9218c0c441b0b937737d2fae33fd1e35>> + * @generated SignedSource<<9b10ad868b2158e959632ca2193654b0>> */ /** @@ -145,6 +145,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enableIOSPartialTextSelection() override { + return false; + } + bool enableIOSTextBaselineOffsetPerLine() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 9113ffb35fdc..6523f4dd2bd1 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -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<<419516aab11d7e44485bb29e696a75dd>> + * @generated SignedSource<> */ /** @@ -308,6 +308,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::enableIOSCompressedTextFrameAdjustment(); } + bool enableIOSPartialTextSelection() override { + auto value = values_["enableIOSPartialTextSelection"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::enableIOSPartialTextSelection(); + } + bool enableIOSTextBaselineOffsetPerLine() override { auto value = values_["enableIOSTextBaselineOffsetPerLine"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index a29aacbef32e..b1d16274c3a9 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -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<> + * @generated SignedSource<<34ce2ac04886ca44d37f774f72086477>> */ /** @@ -56,6 +56,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableFlexboxAutoMinSizeInStrictMode() = 0; virtual bool enableFontScaleChangesUpdatingLayout() = 0; virtual bool enableIOSCompressedTextFrameAdjustment() = 0; + virtual bool enableIOSPartialTextSelection() = 0; virtual bool enableIOSTextBaselineOffsetPerLine() = 0; virtual bool enableIOSViewClipToPaddingBox() = 0; virtual bool enableImagePrefetchingAndroid() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index 65dad77ae6f4..9e284b1c6636 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -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<> + * @generated SignedSource<<7a923d3dd77e45aa82eab0c242aa6138>> */ /** @@ -189,6 +189,11 @@ bool NativeReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment( return ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment(); } +bool NativeReactNativeFeatureFlags::enableIOSPartialTextSelection( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::enableIOSPartialTextSelection(); +} + bool NativeReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index 9e1273933741..369eea8c5aa8 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -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<> + * @generated SignedSource<<73194e9fd319e55f509f4d7441d2c123>> */ /** @@ -96,6 +96,8 @@ class NativeReactNativeFeatureFlags bool enableIOSCompressedTextFrameAdjustment(jsi::Runtime& runtime); + bool enableIOSPartialTextSelection(jsi::Runtime& runtime); + bool enableIOSTextBaselineOffsetPerLine(jsi::Runtime& runtime); bool enableIOSViewClipToPaddingBox(jsi::Runtime& runtime); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 592bc1dc87ec..250b1fdbce26 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -363,6 +363,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableIOSPartialTextSelection: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-10', + description: + 'Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enableIOSTextBaselineOffsetPerLine: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 958225bf8e88..113f096eacd1 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -79,6 +79,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableFlexboxAutoMinSizeInStrictMode: Getter, enableFontScaleChangesUpdatingLayout: Getter, enableIOSCompressedTextFrameAdjustment: Getter, + enableIOSPartialTextSelection: Getter, enableIOSTextBaselineOffsetPerLine: Getter, enableIOSViewClipToPaddingBox: Getter, enableImagePrefetchingAndroid: Getter, @@ -338,6 +339,10 @@ export const enableFontScaleChangesUpdatingLayout: Getter = createNativ * Adjusts iOS Text drawing frames for compressed explicit line heights. */ export const enableIOSCompressedTextFrameAdjustment: Getter = createNativeFlagGetter('enableIOSCompressedTextFrameAdjustment', false); +/** + * Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph. + */ +export const enableIOSPartialTextSelection: Getter = createNativeFlagGetter('enableIOSPartialTextSelection', false); /** * Applies base offset for each line of text separately on iOS. */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index b5585a64a217..884dd8c0a8c4 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -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<<26f30821669e81ddb8c654ea71bbaca6>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -54,6 +54,7 @@ export interface Spec extends TurboModule { readonly enableFlexboxAutoMinSizeInStrictMode?: () => boolean; readonly enableFontScaleChangesUpdatingLayout?: () => boolean; readonly enableIOSCompressedTextFrameAdjustment?: () => boolean; + readonly enableIOSPartialTextSelection?: () => boolean; readonly enableIOSTextBaselineOffsetPerLine?: () => boolean; readonly enableIOSViewClipToPaddingBox?: () => boolean; readonly enableImagePrefetchingAndroid?: () => boolean;