From 2b2cca99658c31b9869d433c845cb1f48a1d3eaf Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 20 Mar 2026 22:33:12 -0500 Subject: [PATCH 1/7] fix(fabric, Text): support native text selection when selectable={true} When the `selectable` prop is set on a `` component in Fabric, swap the content view from a lightweight RCTUIView (RCTParagraphTextView) to a platform-native text view (NSTextView on macOS, UITextView on iOS) that handles both rendering and selection natively. Key design decisions: - Lazy swap: the native text view is only created when selectable={true}, keeping the default non-selectable path (99% of text) zero-overhead - No RCTSurfaceTouchHandler changes: touch cancellation uses a helper that walks the view hierarchy to find and toggle the gesture recognizer - No old-arch RCTTouchHandler dependency: purely Fabric-side solution - Cross-platform: ungates getTextStorageForAttributedString in RCTTextLayoutManager so both iOS and macOS can sync text storage - Proper recycling: selectable text view is torn down in prepareForRecycle macOS mouse event handling (hitTest, mouseDown, rightMouseDown) is ported from the old architecture's RCTTextView.mm with the same peek-ahead drag detection and responder chain management. Co-Authored-By: Claude Opus 4.6 --- .../Text/RCTParagraphComponentView.mm | 339 +++++++++++++++++- .../textlayoutmanager/RCTTextLayoutManager.h | 2 - .../textlayoutmanager/RCTTextLayoutManager.mm | 4 +- 3 files changed, 325 insertions(+), 20 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 b1beb6c931fe..173f092025dc 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -29,10 +29,34 @@ #import "RCTConversions.h" #import "RCTFabricComponentsPlugins.h" +#import // [macOS] + using namespace facebook::react; +#pragma mark - Touch Cancellation Helper + +// Cancel React Native's touch handling by finding the RCTSurfaceTouchHandler +// gesture recognizer in the view hierarchy and toggling its enabled state. +// This matches what RCTSurfaceTouchHandler._cancelTouches does internally. +static void RCTCancelTouchesForView(RCTPlatformView *view) +{ + while (view) { + for (id gr in view.gestureRecognizers) { + if ([gr isKindOfClass:[RCTSurfaceTouchHandler class]]) { + [gr setEnabled:NO]; + [gr setEnabled:YES]; + return; + } + } + view = view.superview; + } +} + +#pragma mark - RCTParagraphTextView (default, non-selectable) + // ParagraphTextView is an auxiliary view we set as contentView so the drawing -// can happen on top of the layers manipulated by RCTViewComponentView (the parent view) +// can happen on top of the layers manipulated by RCTViewComponentView (the parent view). +// Used when selectable={false} (the default). @interface RCTParagraphTextView : RCTUIView // [macOS] @property (nonatomic) ParagraphShadowNode::ConcreteState::Shared state; @@ -41,6 +65,27 @@ @interface RCTParagraphTextView : RCTUIView // [macOS] @end +#pragma mark - RCTParagraphSelectableTextView (selectable) + +// Platform-native text view used when selectable={true}. +// Handles both text rendering and native text selection. +#if TARGET_OS_OSX // [macOS +@interface RCTParagraphSelectableTextView : NSTextView + +- (void)setNeedsDisplay; + +#else // macOS] +@interface RCTParagraphSelectableTextView : UITextView +#endif + +@property (nonatomic) ParagraphShadowNode::ConcreteState::Shared state; +@property (nonatomic) ParagraphAttributes paragraphAttributes; +@property (nonatomic) LayoutMetrics layoutMetrics; + +@end + +#pragma mark - RCTParagraphComponentView + #if !TARGET_OS_OSX // [macOS] @interface RCTParagraphComponentView () @@ -48,7 +93,7 @@ @interface RCTParagraphComponentView () @end #else // [macOS -@interface RCTParagraphComponentView () +@interface RCTParagraphComponentView () @end #endif // [macOS] @@ -57,8 +102,9 @@ @implementation RCTParagraphComponentView { RCTParagraphComponentAccessibilityProvider *_accessibilityProvider; #if !TARGET_OS_OSX // [macOS] UILongPressGestureRecognizer *_longPressGestureRecognizer; -#endif // [macOS] +#endif // macOS] RCTParagraphTextView *_textView; + RCTParagraphSelectableTextView *_selectableTextView; } - (instancetype)initWithFrame:(CGRect)frame @@ -68,7 +114,7 @@ - (instancetype)initWithFrame:(CGRect)frame #if !TARGET_OS_OSX // [macOS] self.opaque = NO; -#endif // [macOS] +#endif // macOS] _textView = [RCTParagraphTextView new]; _textView.backgroundColor = RCTPlatformColor.clearColor; // [macOS] self.contentView = _textView; @@ -91,11 +137,13 @@ - (NSString *)description - (NSAttributedString *_Nullable)attributedText { - if (!_textView.state) { + // Prefer the selectable text view's state if active, otherwise fall back to the default text view. + ParagraphShadowNode::ConcreteState::Shared state = _selectableTextView ? _selectableTextView.state : _textView.state; + if (!state) { return nil; } - return RCTNSAttributedStringFromAttributedString(_textView.state->getData().attributedString); + return RCTNSAttributedStringFromAttributedString(state->getData().attributedString); } #pragma mark - RCTComponentViewProtocol @@ -121,13 +169,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _textView.paragraphAttributes = _paragraphAttributes; if (newParagraphProps.isSelectable != oldParagraphProps.isSelectable) { -#if !TARGET_OS_OSX // [macOS] if (newParagraphProps.isSelectable) { - [self enableContextMenu]; + [self _enableSelection]; } else { - [self disableContextMenu]; + [self _disableSelection]; } -#endif // [macOS] } [super updateProps:props oldProps:oldProps]; @@ -135,9 +181,16 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState { - _textView.state = std::static_pointer_cast(state); + auto concreteState = std::static_pointer_cast(state); + + _textView.state = concreteState; [_textView setNeedsDisplay]; [self setNeedsLayout]; + + if (_selectableTextView) { + _selectableTextView.state = concreteState; + [self _syncSelectableTextStorage]; + } } - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics @@ -149,11 +202,19 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics _textView.layoutMetrics = _layoutMetrics; [_textView setNeedsDisplay]; [self setNeedsLayout]; + + if (_selectableTextView) { + _selectableTextView.layoutMetrics = _layoutMetrics; + [self _syncSelectableTextStorage]; + } } - (void)prepareForRecycle { [super prepareForRecycle]; + if (_selectableTextView) { + [self _disableSelection]; + } _textView.state = nullptr; _accessibilityProvider = nil; } @@ -162,7 +223,117 @@ - (void)layoutSubviews { [super layoutSubviews]; + if (_selectableTextView) { + _selectableTextView.frame = self.bounds; + } else { + _textView.frame = self.bounds; + } +} + +#pragma mark - Selection Management + +- (void)_enableSelection +{ + if (_selectableTextView) { + return; + } + + _selectableTextView = [[RCTParagraphSelectableTextView alloc] initWithFrame:self.bounds]; + _selectableTextView.state = _textView.state; + _selectableTextView.paragraphAttributes = _paragraphAttributes; + _selectableTextView.layoutMetrics = _textView.layoutMetrics; + +#if TARGET_OS_OSX // [macOS + _selectableTextView.delegate = self; + _selectableTextView.usesFontPanel = NO; + _selectableTextView.drawsBackground = NO; + _selectableTextView.linkTextAttributes = @{}; + _selectableTextView.editable = NO; + _selectableTextView.selectable = YES; + _selectableTextView.verticallyResizable = NO; + _selectableTextView.layoutManager.usesFontLeading = NO; +#else // macOS] + _selectableTextView.editable = NO; + _selectableTextView.selectable = YES; + _selectableTextView.scrollEnabled = NO; + _selectableTextView.textContainerInset = UIEdgeInsetsZero; + _selectableTextView.textContainer.lineFragmentPadding = 0; + _selectableTextView.backgroundColor = [UIColor clearColor]; +#endif + + // Sync text content into the native text view. + [self _syncSelectableTextStorage]; + + // Swap: remove the default text view, install the selectable one. + [_textView removeFromSuperview]; + self.contentView = _selectableTextView; + +#if !TARGET_OS_OSX // [macOS] + // On iOS, also enable the context menu (long press to copy). + [self enableContextMenu]; +#endif // macOS] +} + +- (void)_disableSelection +{ + if (!_selectableTextView) { + return; + } + +#if !TARGET_OS_OSX // [macOS] + [self disableContextMenu]; +#endif // macOS] + + // Swap back: remove the selectable text view, restore the default one. + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + + self.contentView = _textView; _textView.frame = self.bounds; + [_textView setNeedsDisplay]; +} + +- (void)_syncSelectableTextStorage +{ + if (!_selectableTextView || !_selectableTextView.state) { + return; + } + + const auto &stateData = _selectableTextView.state->getData(); + auto textLayoutManager = stateData.layoutManager.lock(); + if (!textLayoutManager) { + return; + } + + RCTTextLayoutManager *nativeTextLayoutManager = + (RCTTextLayoutManager *)unwrapManagedObject(textLayoutManager->getNativeTextLayoutManager()); + CGRect frame = RCTCGRectFromRect(_layoutMetrics.getContentFrame()); + + NSTextStorage *textStorage = [nativeTextLayoutManager getTextStorageForAttributedString:stateData.attributedString + paragraphAttributes:_paragraphAttributes + size:frame.size]; + +#if TARGET_OS_OSX // [macOS + // Sync the layout infrastructure into the NSTextView. + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + [_selectableTextView replaceTextContainer:textContainer]; + + // Detach layout managers from the source text storage before syncing content. + NSArray *managers = [[textStorage layoutManagers] copy]; + for (NSLayoutManager *manager in managers) { + [textStorage removeLayoutManager:manager]; + } + + _selectableTextView.minSize = frame.size; + _selectableTextView.maxSize = frame.size; + _selectableTextView.frame = frame; + [[_selectableTextView textStorage] setAttributedString:textStorage]; +#else // macOS] + // On iOS, set the attributed text directly. UITextView manages its own layout. + _selectableTextView.attributedText = textStorage; + _selectableTextView.frame = frame; +#endif } #pragma mark - Accessibility @@ -197,11 +368,12 @@ - (NSArray *)accessibilityElements // If the component is not `accessible`, we return an empty array. // We do this because logically all nested components represent the content of the component; // in other words, all nested components individually have no sense without the . - if (!_textView.state || !paragraphProps.accessible) { + ParagraphShadowNode::ConcreteState::Shared state = _selectableTextView ? _selectableTextView.state : _textView.state; + if (!state || !paragraphProps.accessible) { return [NSArray new]; } - auto &data = _textView.state->getData(); + auto &data = state->getData(); if (![_accessibilityProvider isUpToDate:data.attributedString]) { auto textLayoutManager = data.layoutManager.lock(); @@ -278,7 +450,7 @@ - (NSAccessibilityRole)accessibilityRole - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point { - const auto &state = _textView.state; + ParagraphShadowNode::ConcreteState::Shared state = _selectableTextView ? _selectableTextView.state : _textView.state; if (!state) { return _eventEmitter; } @@ -352,6 +524,89 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture } #endif // [macOS] +#pragma mark - macOS Mouse Event Handling + +#if TARGET_OS_OSX // [macOS + +- (NSView *)hitTest:(NSPoint)point +{ + NSView *hitView = [super hitTest:point]; + + if (!_selectableTextView) { + return hitView; + } + + // Intercept clicks on the selectable text view so we can manage selection ourselves, + // preventing it from swallowing events that may be handled in JS (e.g. onPress). + NSEventType eventType = NSApp.currentEvent.type; + BOOL isMouseClickEvent = NSEvent.pressedMouseButtons > 0; + BOOL isMouseMoveEventType = eventType == NSEventTypeMouseMoved || + eventType == NSEventTypeMouseEntered || + eventType == NSEventTypeMouseExited || + eventType == NSEventTypeCursorUpdate; + BOOL isMouseMoveEvent = !isMouseClickEvent && isMouseMoveEventType; + BOOL isTextViewClick = (hitView && hitView == _selectableTextView) && !isMouseMoveEvent; + + return isTextViewClick ? self : hitView; +} + +- (void)rightMouseDown:(NSEvent *)event +{ + if (!_selectableTextView) { + [super rightMouseDown:event]; + return; + } + + RCTCancelTouchesForView(self); + [_selectableTextView rightMouseDown:event]; +} + +- (void)mouseDown:(NSEvent *)event +{ + if (!_selectableTextView) { + [super mouseDown:event]; + return; + } + + // Double/triple-clicks should be forwarded to the NSTextView for word/line selection. + BOOL shouldForward = event.clickCount > 1; + + if (!shouldForward) { + // Peek at next event to know if a drag (selection) is beginning. + NSEvent *nextEvent = [self.window nextEventMatchingMask:NSEventMaskLeftMouseUp | NSEventMaskLeftMouseDragged + untilDate:[NSDate distantFuture] + inMode:NSEventTrackingRunLoopMode + dequeue:NO]; + shouldForward = nextEvent.type == NSEventTypeLeftMouseDragged; + } + + if (shouldForward) { + NSView *contentView = self.window.contentView; + // -[NSView hitTest:] takes coordinates in a view's superview coordinate system. + NSPoint point = [contentView.superview convertPoint:event.locationInWindow fromView:nil]; + + if ([contentView hitTest:point] == self) { + RCTCancelTouchesForView(self); + [self.window makeFirstResponder:_selectableTextView]; + [_selectableTextView mouseDown:event]; + } + } else { + // Clear selection for single clicks. + _selectableTextView.selectedRange = NSMakeRange(NSNotFound, 0); + } +} + +#pragma mark - NSTextViewDelegate + +- (void)textDidEndEditing:(NSNotification *)notification +{ + _selectableTextView.selectedRange = NSMakeRange(NSNotFound, 0); +} + +#endif // macOS] + +#pragma mark - Responder Chain + - (BOOL)canBecomeFirstResponder { const auto ¶graphProps = static_cast(*_props); @@ -369,7 +624,19 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return [self.nextResponder canPerformAction:action withSender:sender]; } -#endif // [macOS] +#else // [macOS + +- (BOOL)resignFirstResponder +{ + // Don't relinquish first responder while selecting text. + if (_selectableTextView && NSRunLoop.currentRunLoop.currentMode == NSEventTrackingRunLoopMode) { + return NO; + } + + return [super resignFirstResponder]; +} + +#endif // macOS] - (void)copy:(id)sender { @@ -404,6 +671,8 @@ - (void)copy:(id)sender return RCTParagraphComponentView.class; } +#pragma mark - RCTParagraphTextView Implementation + @implementation RCTParagraphTextView { CAShapeLayer *_highlightLayer; } @@ -450,3 +719,43 @@ - (void)drawRect:(CGRect)rect } @end + +#pragma mark - RCTParagraphSelectableTextView Implementation + +@implementation RCTParagraphSelectableTextView + +#if TARGET_OS_OSX // [macOS + +- (void)setNeedsDisplay +{ + [self setNeedsDisplay:YES]; +} + +- (BOOL)canBecomeKeyView +{ + // Prevent this NSTextView from participating in the key view loop directly. + // The parent RCTParagraphComponentView manages focus instead. + return NO; +} + +- (BOOL)resignFirstResponder +{ + // Don't relinquish first responder while the user is actively selecting text. + if (self.selectable && NSRunLoop.currentRunLoop.currentMode == NSEventTrackingRunLoopMode) { + return NO; + } + + return [super resignFirstResponder]; +} + +#else // macOS] + +- (RCTUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + // Let the parent RCTParagraphComponentView handle touch routing. + return nil; +} + +#endif + +@end 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 adb9430531e1..ea0eccfad14a 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 @@ -59,11 +59,9 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block; -#if TARGET_OS_OSX // [macOS - (NSTextStorage *)getTextStorageForAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes size:(CGSize)size; -#endif // macOS] @end 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 ac7152c7ec48..bd3743ee3086 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 @@ -438,7 +438,6 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage return TextMeasurement{{size.width, size.height}, attachments}; } -#if TARGET_OS_OSX // [macOS - (NSTextStorage *)getTextStorageForAttributedString:(AttributedString)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes size:(CGSize)size @@ -447,9 +446,8 @@ - (NSTextStorage *)getTextStorageForAttributedString:(AttributedString)attribute NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:nsAttributedString paragraphAttributes:paragraphAttributes size:size]; - + return textStorage; } -#endif // macOS] @end From a8684285c9076960966a60179ed7a7e55b86ab59 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 20 Mar 2026 22:54:37 -0500 Subject: [PATCH 2/7] fix: align macOS diff tags with project conventions Update all #if TARGET_OS_OSX / #if !TARGET_OS_OSX blocks and inline comments to follow the macOS tag format documented in docsite/docs/contributing/diffs-with-upstream.md. Co-Authored-By: Claude Opus 4.6 --- .../Text/RCTParagraphComponentView.mm | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 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 173f092025dc..7e85f25187bb 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -29,15 +29,16 @@ #import "RCTConversions.h" #import "RCTFabricComponentsPlugins.h" -#import // [macOS] +#if TARGET_OS_OSX // [macOS +#import +#endif // macOS] using namespace facebook::react; -#pragma mark - Touch Cancellation Helper - -// Cancel React Native's touch handling by finding the RCTSurfaceTouchHandler +// [macOS Cancel React Native's touch handling by finding the RCTSurfaceTouchHandler // gesture recognizer in the view hierarchy and toggling its enabled state. // This matches what RCTSurfaceTouchHandler._cancelTouches does internally. +#if TARGET_OS_OSX static void RCTCancelTouchesForView(RCTPlatformView *view) { while (view) { @@ -51,12 +52,13 @@ static void RCTCancelTouchesForView(RCTPlatformView *view) view = view.superview; } } +#endif +// macOS] #pragma mark - RCTParagraphTextView (default, non-selectable) // ParagraphTextView is an auxiliary view we set as contentView so the drawing -// can happen on top of the layers manipulated by RCTViewComponentView (the parent view). -// Used when selectable={false} (the default). +// can happen on top of the layers manipulated by RCTViewComponentView (the parent view) @interface RCTParagraphTextView : RCTUIView // [macOS] @property (nonatomic) ParagraphShadowNode::ConcreteState::Shared state; @@ -65,16 +67,14 @@ @interface RCTParagraphTextView : RCTUIView // [macOS] @end -#pragma mark - RCTParagraphSelectableTextView (selectable) - -// Platform-native text view used when selectable={true}. +// [macOS Platform-native text view used when selectable={true}. // Handles both text rendering and native text selection. -#if TARGET_OS_OSX // [macOS +#if TARGET_OS_OSX @interface RCTParagraphSelectableTextView : NSTextView - (void)setNeedsDisplay; -#else // macOS] +#else @interface RCTParagraphSelectableTextView : UITextView #endif @@ -83,6 +83,7 @@ @interface RCTParagraphSelectableTextView : UITextView @property (nonatomic) LayoutMetrics layoutMetrics; @end +// macOS] #pragma mark - RCTParagraphComponentView @@ -95,16 +96,16 @@ @interface RCTParagraphComponentView () #else // [macOS @interface RCTParagraphComponentView () @end -#endif // [macOS] +#endif // macOS] @implementation RCTParagraphComponentView { ParagraphAttributes _paragraphAttributes; RCTParagraphComponentAccessibilityProvider *_accessibilityProvider; #if !TARGET_OS_OSX // [macOS] UILongPressGestureRecognizer *_longPressGestureRecognizer; -#endif // macOS] +#endif // [macOS] RCTParagraphTextView *_textView; - RCTParagraphSelectableTextView *_selectableTextView; + RCTParagraphSelectableTextView *_selectableTextView; // [macOS] } - (instancetype)initWithFrame:(CGRect)frame @@ -114,7 +115,7 @@ - (instancetype)initWithFrame:(CGRect)frame #if !TARGET_OS_OSX // [macOS] self.opaque = NO; -#endif // macOS] +#endif // [macOS] _textView = [RCTParagraphTextView new]; _textView.backgroundColor = RCTPlatformColor.clearColor; // [macOS] self.contentView = _textView; @@ -259,7 +260,7 @@ - (void)_enableSelection _selectableTextView.textContainerInset = UIEdgeInsetsZero; _selectableTextView.textContainer.lineFragmentPadding = 0; _selectableTextView.backgroundColor = [UIColor clearColor]; -#endif +#endif // macOS] // Sync text content into the native text view. [self _syncSelectableTextStorage]; @@ -271,7 +272,7 @@ - (void)_enableSelection #if !TARGET_OS_OSX // [macOS] // On iOS, also enable the context menu (long press to copy). [self enableContextMenu]; -#endif // macOS] +#endif // [macOS] } - (void)_disableSelection @@ -282,7 +283,7 @@ - (void)_disableSelection #if !TARGET_OS_OSX // [macOS] [self disableContextMenu]; -#endif // macOS] +#endif // [macOS] // Swap back: remove the selectable text view, restore the default one. [_selectableTextView removeFromSuperview]; @@ -333,7 +334,7 @@ - (void)_syncSelectableTextStorage // On iOS, set the attributed text directly. UITextView manages its own layout. _selectableTextView.attributedText = textStorage; _selectableTextView.frame = frame; -#endif +#endif // macOS] } #pragma mark - Accessibility @@ -360,7 +361,7 @@ - (BOOL)isAccessibilityElement return NO; } -#if !TARGET_OS_OSX // [macOS +#if !TARGET_OS_OSX // [macOS] - (NSArray *)accessibilityElements { const auto ¶graphProps = static_cast(*_props); @@ -720,11 +721,12 @@ - (void)drawRect:(CGRect)rect @end +// [macOS #pragma mark - RCTParagraphSelectableTextView Implementation @implementation RCTParagraphSelectableTextView -#if TARGET_OS_OSX // [macOS +#if TARGET_OS_OSX - (void)setNeedsDisplay { @@ -748,7 +750,7 @@ - (BOOL)resignFirstResponder return [super resignFirstResponder]; } -#else // macOS] +#else - (RCTUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { @@ -759,3 +761,4 @@ - (RCTUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event #endif @end +// macOS] From f885a507a25d3486fdaebd9b68723b7ff8f0b239 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 20 Mar 2026 22:58:17 -0500 Subject: [PATCH 3/7] refactor: minimize diff vs upstream by using _textView.state as source of truth Since _textView.state is always kept in sync in updateState:, there's no need to duplicate state onto _selectableTextView or use ternary expressions to pick between them. This removes unnecessary modifications to attributedText, accessibilityElements, and touchEventEmitterAtPoint:, keeping those methods identical to upstream. Co-Authored-By: Claude Opus 4.6 --- .../Text/RCTParagraphComponentView.mm | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 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 7e85f25187bb..881c5cc0de0c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -78,10 +78,6 @@ - (void)setNeedsDisplay; @interface RCTParagraphSelectableTextView : UITextView #endif -@property (nonatomic) ParagraphShadowNode::ConcreteState::Shared state; -@property (nonatomic) ParagraphAttributes paragraphAttributes; -@property (nonatomic) LayoutMetrics layoutMetrics; - @end // macOS] @@ -138,13 +134,11 @@ - (NSString *)description - (NSAttributedString *_Nullable)attributedText { - // Prefer the selectable text view's state if active, otherwise fall back to the default text view. - ParagraphShadowNode::ConcreteState::Shared state = _selectableTextView ? _selectableTextView.state : _textView.state; - if (!state) { + if (!_textView.state) { return nil; } - return RCTNSAttributedStringFromAttributedString(state->getData().attributedString); + return RCTNSAttributedStringFromAttributedString(_textView.state->getData().attributedString); } #pragma mark - RCTComponentViewProtocol @@ -182,14 +176,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState { - auto concreteState = std::static_pointer_cast(state); - - _textView.state = concreteState; + _textView.state = std::static_pointer_cast(state); [_textView setNeedsDisplay]; [self setNeedsLayout]; if (_selectableTextView) { - _selectableTextView.state = concreteState; [self _syncSelectableTextStorage]; } } @@ -205,7 +196,6 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics [self setNeedsLayout]; if (_selectableTextView) { - _selectableTextView.layoutMetrics = _layoutMetrics; [self _syncSelectableTextStorage]; } } @@ -240,9 +230,6 @@ - (void)_enableSelection } _selectableTextView = [[RCTParagraphSelectableTextView alloc] initWithFrame:self.bounds]; - _selectableTextView.state = _textView.state; - _selectableTextView.paragraphAttributes = _paragraphAttributes; - _selectableTextView.layoutMetrics = _textView.layoutMetrics; #if TARGET_OS_OSX // [macOS _selectableTextView.delegate = self; @@ -296,11 +283,11 @@ - (void)_disableSelection - (void)_syncSelectableTextStorage { - if (!_selectableTextView || !_selectableTextView.state) { + if (!_selectableTextView || !_textView.state) { return; } - const auto &stateData = _selectableTextView.state->getData(); + const auto &stateData = _textView.state->getData(); auto textLayoutManager = stateData.layoutManager.lock(); if (!textLayoutManager) { return; @@ -369,12 +356,11 @@ - (NSArray *)accessibilityElements // If the component is not `accessible`, we return an empty array. // We do this because logically all nested components represent the content of the component; // in other words, all nested components individually have no sense without the . - ParagraphShadowNode::ConcreteState::Shared state = _selectableTextView ? _selectableTextView.state : _textView.state; - if (!state || !paragraphProps.accessible) { + if (!_textView.state || !paragraphProps.accessible) { return [NSArray new]; } - auto &data = state->getData(); + auto &data = _textView.state->getData(); if (![_accessibilityProvider isUpToDate:data.attributedString]) { auto textLayoutManager = data.layoutManager.lock(); @@ -451,7 +437,7 @@ - (NSAccessibilityRole)accessibilityRole - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point { - ParagraphShadowNode::ConcreteState::Shared state = _selectableTextView ? _selectableTextView.state : _textView.state; + const auto &state = _textView.state; if (!state) { return _eventEmitter; } From a81f13b166970f388a28a3678aab9986b78fc491 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 20 Mar 2026 23:14:44 -0500 Subject: [PATCH 4/7] fix(iOS): let UITextView handle touches natively for text selection Remove hitTest:withEvent: override on iOS in RCTParagraphSelectableTextView so UITextView receives touches and handles selection natively. Co-Authored-By: Claude Opus 4.6 --- .../ComponentViews/Text/RCTParagraphComponentView.mm | 8 +++----- 1 file changed, 3 insertions(+), 5 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 881c5cc0de0c..7557b141faf2 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -738,11 +738,9 @@ - (BOOL)resignFirstResponder #else -- (RCTUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event -{ - // Let the parent RCTParagraphComponentView handle touch routing. - return nil; -} +// On iOS, let UITextView handle touches natively for selection support. +// No hitTest override needed — UITextView's built-in gesture recognizers +// handle long-press-to-select and other selection interactions. #endif From 98e842c691f6ecbca9928a40aeb3da6705c3d6df Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 20 Mar 2026 23:43:26 -0500 Subject: [PATCH 5/7] fix: address PR review comments - Move macOS diff tag inline with #if TARGET_OS_OSX macro - Move comment below macro, above function definition - Rename `gr` to `gestureHandler` (no abbreviations) - Add macOS diff tags to new #pragma mark lines Co-Authored-By: Claude Opus 4.6 --- .../Text/RCTParagraphComponentView.mm | 19 +++++++++---------- 1 file changed, 9 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 7557b141faf2..4729d2c2feda 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -35,27 +35,26 @@ using namespace facebook::react; -// [macOS Cancel React Native's touch handling by finding the RCTSurfaceTouchHandler +#if TARGET_OS_OSX // [macOS +// Cancel React Native's touch handling by finding the RCTSurfaceTouchHandler // gesture recognizer in the view hierarchy and toggling its enabled state. // This matches what RCTSurfaceTouchHandler._cancelTouches does internally. -#if TARGET_OS_OSX static void RCTCancelTouchesForView(RCTPlatformView *view) { while (view) { - for (id gr in view.gestureRecognizers) { - if ([gr isKindOfClass:[RCTSurfaceTouchHandler class]]) { - [gr setEnabled:NO]; - [gr setEnabled:YES]; + for (id gestureHandler in view.gestureRecognizers) { + if ([gestureHandler isKindOfClass:[RCTSurfaceTouchHandler class]]) { + [gestureHandler setEnabled:NO]; + [gestureHandler setEnabled:YES]; return; } } view = view.superview; } } -#endif -// macOS] +#endif // macOS] -#pragma mark - RCTParagraphTextView (default, non-selectable) +#pragma mark - RCTParagraphTextView (default, non-selectable) // [macOS] // ParagraphTextView is an auxiliary view we set as contentView so the drawing // can happen on top of the layers manipulated by RCTViewComponentView (the parent view) @@ -81,7 +80,7 @@ @interface RCTParagraphSelectableTextView : UITextView @end // macOS] -#pragma mark - RCTParagraphComponentView +#pragma mark - RCTParagraphComponentView // [macOS] #if !TARGET_OS_OSX // [macOS] @interface RCTParagraphComponentView () From 0aa2f812eba943fb7dcb13ae2efe1056126b0c10 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sat, 21 Mar 2026 00:00:34 -0500 Subject: [PATCH 6/7] fix: add macOS diff tag to RCTParagraphSelectableTextView #if block Co-Authored-By: Claude Opus 4.6 --- .../Mounting/ComponentViews/Text/RCTParagraphComponentView.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4729d2c2feda..22fd529a4f75 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -68,7 +68,7 @@ @interface RCTParagraphTextView : RCTUIView // [macOS] // [macOS Platform-native text view used when selectable={true}. // Handles both text rendering and native text selection. -#if TARGET_OS_OSX +#if TARGET_OS_OSX // [macOS @interface RCTParagraphSelectableTextView : NSTextView - (void)setNeedsDisplay; From 73b644aa79f054e391793d1fecc39700edb8301b Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sat, 21 Mar 2026 00:08:50 -0500 Subject: [PATCH 7/7] fix: address PR review comments (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add [macOS iOS] diff tags to getTextStorageForAttributedString in .h and .mm - Add comment in updateProps explaining enableContextMenu → _enableSelection rename - Rename _syncSelectableTextStorage → updateSelectableTextStorage Co-Authored-By: Claude Opus 4.6 --- .../ComponentViews/Text/RCTParagraphComponentView.mm | 11 +++++++---- .../renderer/textlayoutmanager/RCTTextLayoutManager.h | 2 ++ .../textlayoutmanager/RCTTextLayoutManager.mm | 2 ++ 3 files changed, 11 insertions(+), 4 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 22fd529a4f75..6c2b6a113b90 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -163,11 +163,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _textView.paragraphAttributes = _paragraphAttributes; if (newParagraphProps.isSelectable != oldParagraphProps.isSelectable) { + // [macOS Replaced enableContextMenu/disableContextMenu with _enableSelection/_disableSelection + // to swap in a native text view that supports text selection. if (newParagraphProps.isSelectable) { [self _enableSelection]; } else { [self _disableSelection]; } + // macOS] } [super updateProps:props oldProps:oldProps]; @@ -180,7 +183,7 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & [self setNeedsLayout]; if (_selectableTextView) { - [self _syncSelectableTextStorage]; + [self updateSelectableTextStorage]; } } @@ -195,7 +198,7 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics [self setNeedsLayout]; if (_selectableTextView) { - [self _syncSelectableTextStorage]; + [self updateSelectableTextStorage]; } } @@ -249,7 +252,7 @@ - (void)_enableSelection #endif // macOS] // Sync text content into the native text view. - [self _syncSelectableTextStorage]; + [self updateSelectableTextStorage]; // Swap: remove the default text view, install the selectable one. [_textView removeFromSuperview]; @@ -280,7 +283,7 @@ - (void)_disableSelection [_textView setNeedsDisplay]; } -- (void)_syncSelectableTextStorage +- (void)updateSelectableTextStorage { if (!_selectableTextView || !_textView.state) { return; 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 ea0eccfad14a..40f44f78dfa9 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 @@ -59,9 +59,11 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block; +// [macOS iOS - (NSTextStorage *)getTextStorageForAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes size:(CGSize)size; +// macOS iOS] @end 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 bd3743ee3086..61ec47af8632 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 @@ -438,6 +438,7 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage return TextMeasurement{{size.width, size.height}, attachments}; } +// [macOS iOS - (NSTextStorage *)getTextStorageForAttributedString:(AttributedString)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes size:(CGSize)size @@ -449,5 +450,6 @@ - (NSTextStorage *)getTextStorageForAttributedString:(AttributedString)attribute return textStorage; } +// macOS iOS] @end