Skip to content

feat: support partial text selection on iOS <Text selectable> with feature flag - #58363

Open
raajnadar wants to merge 16 commits into
react:mainfrom
raajnadar:fix/ios-partial-text-selection
Open

raajnadar wants to merge 16 commits into
react:mainfrom
raajnadar:fix/ios-partial-text-selection

Conversation

@raajnadar

@raajnadar raajnadar commented Sep 6, 2026 •

Copy link
Copy Markdown

Summary:

On iOS, <Text selectable> can only copy the whole paragraph. There is no highlight and no drag handles, so you cannot select part of it. Android has allowed this since the prop was added.

Fixes #13938, open since 2017. Also covers #55187, which reports the same gap as an iOS 26 regression‚ it is not a regression. iOS selection has always copied the whole block.

Changelog:

[IOS] [ADDED] - Add the enableIOSPartialTextSelection feature flag. When the flag is on, a long press on <Text selectable> selects a range of text, and Copy copies only that range.

Test Plan:

Tested on RNTester on an iPhone 16 Pro simulator, iOS 18.6, built with:

xcodebuild build -workspace RNTesterPods.xcworkspace -scheme RNTester -configuration Debug \
  -destination 'platform=iOS Simulator,name=iPhone 16 Pro'

Before and after were captured on the same simulator, with the same paragraph and the same gesture

before-copy-only after-partial-selection
Before After
Long press, then drag One Copy bubble for the whole block, no highlight, no handles A substring selected, both drag handles work

Each remaining case rendered a selectable paragraph beside an identical non-selectable control, so any difference is attributable to this change.

Case Result
Edit menu Copy, Look Up and Translate, acting on the selected range
onPress on a nested <Text> Fires on a short tap; tap counter matched the control exactly
numberOfLines={2} Same truncation point and ellipsis as the control
Inline <View> child Same position and text flow as the control
Console No warnings or errors

yarn format reports no changes on the three files.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 6, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 6, 2026
@javache

javache commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

This is quite a change in behaviour. Should these be separate properties so developers can choose which one they want?

@elstgav

elstgav commented Sep 8, 2026

Copy link
Copy Markdown

@javache it does change the behavior, but I don’t think it introduces a second valid selection mode that needs an opt-in.

selectable’s documentation promises native copy/paste through text selection. On Android, and in standard iOS text controls, that means a user can select a range, see the selection, adjust it with handles, and have the edit menu operate on that range. iOS <Text selectable> currently does none of those things: a long press silently produces a single floating Copy button, with no indication of what range is going to be copied, and the copy button flashes repeatedly as you drag over the text. It’s an incomplete (and I strongly believe incorrect) implementation of the existing prop. I don’t think it’s a meaningful behavior React Native should preserve as an alternative.

An app that intentionally wants “long press copies this whole block” has clearer, explicit ways to implement that interaction (for example, a long-press handler that copies a known string). And implementing this correctly still allows for the previous behavior: you can select the entire block of text and still copy it—so not really a “breaking” change, though slightly different in the UX.

It is behavior-changing for apps that may have relied on it, but it’s appropriately a fix: it brings the documented native-selection affordance into line with Android and normal iOS expectations.

@raajnadar

Copy link
Copy Markdown
Author

I was about to say the exact same thing. Adding a new prop will add a lot of additional work if we consider that approach.

P.S. - Solved the CI error, which trimmed the code by a few lines

@javache

javache commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Lets make the changelog entry here more explicit, and call out that the default behaviour for selectable is quite different with this change.

@javache
javache requested a review from Abbondanzo September 9, 2026 12:05

@Abbondanzo Abbondanzo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@javache's comment is fair--this is bringing iOS to parity with Android but should be marked as a breaking change just so we can call it out in the changelog. I was poking through and found some rendering issues with the new approach that should get fixed up first (especially pressable text)

@raajnadar

raajnadar commented Sep 12, 2026 •

Copy link
Copy Markdown
Author

Good catch. The cause was one thing: the UITextView both laid the text out and painted it, so every effect RCTTextLayoutManager draws was lost.

Split those two jobs:

  • RCTParagraphTextView draws the paragraph again — no longer hidden — so decorations, compressed line heights and the pressed highlight all come back.
  • The selection text view gets the string with the painting attributes stripped (foreground and background clear; underline, strikethrough, shadow dropped) and the layout attributes kept. It places the selection rects and paints nothing.
  • It sits below the drawn paragraph, and uses drawingFrame rather than the content frame so the rects stay on the glyphs.
  • Now a tap outside clears the selection, matching Android.

Your review also turned up a regression I had missed: removing the old copy: left UITextView copying from its own storage — the layout copy — so a paste into Notes or Mail arrived invisible. copy: now writes the selected range from the painted string, as the old code did for the whole block.

Added RCTParagraphSelectionTests: the paint/layout split, Copy carrying the painted colour, no extra view when selectable is false, plus toggling, recycling and accessibility. If the test should go on a separate PR, let me know I will split it.

P.S. - Update the changelog please check

@raajnadar

Copy link
Copy Markdown
Author

I solved the merge conflicts, and it seems the CI didn't fail due to my changes

@ivannzme

Copy link
Copy Markdown

Created a patch fix based on this PR and ran it in a production app on React Native 0.86.3, Expo SDK 57. Body text there is one Text per paragraph, built from nested Text nodes for bold, italic and links.

Where the nested nodes are styling only, selection works: handles, and Copy, Look Up, Translate, Search Web and Share acting on the selected range.

A paragraph holding a nested pressable Text does not select at all. Long press anywhere in it gives nothing, no highlight and no menu, while the paragraph directly below in the same article selects normally. Tapping the link still fires its onPress, so touches reach React Native and the selection simply never starts. Two attempts at two points each, iOS 26.4 simulator.

One change did not fit 0.86.3. That release has neither _textLayoutFrame nor the call site this hooks into, so I refresh the selectable text view from layoutSubviews with RCTCGRectFromRect(_layoutMetrics.getContentFrame()). Everything else went in unchanged, just at different line numbers.

Happy to retest once pressable text is sorted. Looking forward to seeing this merged.

@raajnadar

Copy link
Copy Markdown
Author

@ivannzme thank you for patching this into a real app and testing it. That report found a genuine defect, and it would not have shown up in the examples I had.

The cause. A press on a nested paints a highlight, which changed the attributed string. The selection view rebuilt itself in the middle of the touch, so UITextView lost the long press it had already started.

The fix. The rebuild now follows the layout string instead of the painted one, so a paint-only change no longer recreates the view.

That exposed a second problem: onPress fired while selecting, because React Native's touch handler and the selection gesture track the same touch. The paragraph now gives up the touch once a selection shows.

Checked by hand on RNTester, iOS 18.6: pressable children select, decorations survive, scrolling and ordinary link taps still work.

@ivannzme a retest would be helpfu

@ivannzme

Copy link
Copy Markdown

Retested the updated branch on React Native 0.86.3, Expo SDK 57. The pressable-text fix works: a paragraph carrying a nested pressable Text selects, the selection extends across the link, the underline survives, and ordinary taps on the link still navigate.

One new problem, on recycled views. prepareForRecycle calls disableContextMenu, which nils _selectionLayoutManager. updateProps only calls enableContextMenu when isSelectable changes, and RCTViewComponentView.prepareForRecycle does not reset _props, so a recycled paragraph reused for another selectable paragraph never re-enables. layoutSubviews then builds the text view from a nil text storage and the paragraph is silently dead.

It reads as intermittency from the outside. After a fresh launch the first article selects fine. Once enough screens have been visited for the view pool to warm up, every paragraph is dead, including articles opened for the first time.

Creating the layout manager lazily in updateSelectableTextViewWithDrawingFrame: fixed it here:

if (_selectionLayoutManager == nil) {
  _selectionLayoutManager = [RCTTextLayoutManager new];
}

Eight of eight paragraphs across four article opens after that, including a re-opened article and one with an inline link.

@raajnadar

Copy link
Copy Markdown
Author

@ivannzme thanks, I updated the PR

@raajnadar

Copy link
Copy Markdown
Author

@Abbondanzo are we good with the changes? Is anything pending on my end?

@meta-codesync

meta-codesync Bot commented Oct 1, 2026

Copy link
Copy Markdown

@Abbondanzo has imported this pull request. If you are a Meta employee, you can view this in D122903141.

@Abbondanzo Abbondanzo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raajnadar Received some fair feedback internally that I'd like to pass along:

  • Could you split out the new RCTSelectableTextView interface and implementation into a separate file(s)?
  • Could you put this change behind a feature gate? The existing behavior should be retained when the gate is off (default off for now). We can run an experiment in turn across our apps to ensure this is rolled out safely

@raajnadar raajnadar changed the title fix: support partial text selection in <Text selectable> feat: support partial text selection on iOS <Text selectable> with feature flag Oct 10, 2026
@raajnadar

Copy link
Copy Markdown
Author

@Abbondanzo, thanks for passing this along. Both points are done:

  1. RCTSelectableTextView is now in RCTSelectableTextView+Internal.h and RCTSelectableTextView.mm. I used the +Internal.h name because scripts/cxx-api/config.yml skips *+Internal.h, so the class stays out of the public API snapshots for now.

  2. The change is behind enableIOSPartialTextSelection, with defaultValue: false like the reviewer suggested and ossReleaseStage: 'none'. With the flag off, RCTParagraphComponentView behaves as on the main branch.

Now, as the PR is non-breaking, I updated the title and the changelog entry

@raajnadar
raajnadar requested a review from Abbondanzo October 14, 2026 19:53
@raajnadar

Copy link
Copy Markdown
Author

As 0.88.0 is released, I think we can put this on a minor release

@cortinico cortinico left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review automatically exported from Phabricator review in Meta.

@cipolleschi cipolleschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this, partial text selection on iOS is a long-standing request, and having it behind a feature flag is the right call.
I left some inline comments. My main concerns are touch handling (inline children, pointerEvents, concurrent touches), backgrounds covering the selection highlight, and the cost of a second TextKit stack for every selectable paragraph. Could you also add the missing tests and some perf measurements to the Test Plan?

Comment on lines +580 to +581
BOOL needsRebuild = _selectableTextView == nil || ![layoutText isEqualToAttributedString:_selectionRenderedText] ||
!CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rebuild check only compares the attributed string and the frame size, but the builder also reads maximumNumberOfLines, ellipsizeMode, adjustsFontSizeToFit and the minimum font scale. What happens if a fixed-size paragraph switches from ellipsizeMode="head" to "tail"? As far as I can tell, the selection container keeps the old configuration, and a props-only update never marks the selection as dirty.

Could we include the paragraph attributes in the invalidation check and schedule an update when they change? A test that changes these props on a fixed-frame paragraph would help. The current tests only cover the initial render.

Comment on lines +594 to +602
// 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];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inserting the text view right below _textView raises a few questions:

  • Inline children: they're mounted starting at index 0, so this view ends up above them. Hit testing goes in reverse order and the drawing view returns nil. So which view gets the touch when the user taps an inline Pressable, TextInput or native control? I think it's the selection UITextView, even when nothing is selected. The test plan checks that inline views are positioned correctly, but not that they're still interactive.
  • pointerEvents="box-none": before, a plain paragraph let touches through because the drawing view returned no hit. Now the UITextView is a child that returns a hit. Wouldn't it steal touches from the controls underneath?
  • pointerEvents="box-only": hit testing stops at the paragraph, so the UITextView recognizers never see the touch. Now that the paragraph-level long-press recognizer is gone, can the user still select text in this mode?
  • Backgrounds: the selection view sits below the paragraph's background pass, not just below the glyphs. If a nested <Text> has an opaque backgroundColor, drawBackgroundForGlyphRange: still paints it in the drawing view above. Wouldn't that cover the selection highlight? Stripping the background attributes from the selection storage doesn't change what the upper view paints.

Could we keep inline children ahead of the selection view in hit testing (also after a rebuild), preserve the pointerEvents behaviors without the auxiliary view becoming a touch target, and keep the order background → selection → glyphs? A render test that selects a range inside a nested text with an opaque background would catch the last one.

Comment on lines +87 to +96
- (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;
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabling and re-enabling RCTSurfaceTouchHandler resets all of its active touches, not just the one that started the selection. What happens if the user holds a finger on another control and selects text with a second finger? I believe the other control's touch gets cancelled too. Also, with nested hosting, the ancestor walk may reset more than the nearest handler. Is that expected?

Could we scope the cancellation to the selection interaction? A regression test with a second, concurrent touch outside the paragraph would cover this.

Comment on lines +397 to +399
if (_selectableTextView.selectedRange.length > 0) {
return nullptr;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While there's a selection, this returns a null emitter for any touch on the paragraph, wherever it lands. Is that intended? For example, take a padded paragraph inside a Pressable. A tap in the padding, outside the selection view, doesn't fall back to the parent. Dismissing the selection on release can't restore the emitter captured on touch down either.

What about W3C pointer events? Pointer dispatch calls this getter again for pointerUp and pointerCancel, but the selection only resets the touch handler. If the selection becomes non-empty after pointerDown, isn't the final pointer event dropped? Can you verify the callback ordering and the pointer-capture cleanup on a device?

* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a UITextView and a separate TextKit stack for every selectable paragraph, before the user interacts with it. It also doesn't reuse the stack we already built for measurement. Even when nothing needs rebuilding, we convert the whole attributed string, strip it and compare it. When the flag is first enabled, both _updateTextViewFrame and the pending-update branch in finalizeUpdates call this method, so the work happens twice.

Could we do this once per mutation? And can you attach flag-off/flag-on measurements for main-thread mounting, scroll frame times and retained memory? The interesting cases are long paragraphs with rich attributes, text updates and recycled list cells.

Comment on lines +317 to +319
XCTAssertNil(attributes[NSUnderlineStyleAttributeName]);
XCTAssertNil(attributes[NSStrikethroughStyleAttributeName]);
XCTAssertNil(attributes[NSShadowAttributeName]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few gaps in the tests:

  • The paint-stripping assertions don't prove much. The fixture uses a wavy decoration (our custom attribute) and no shadow, so the native underline, strikethrough and shadow attributes are nil from the start. Could we add fixtures with a solid decoration and a real shadow, and first assert that the input contains them?
  • The layout fixtures use default paragraph attributes and a synthetic size, and they don't compare the selection rects with the painted glyphs. How do we know the two match with adjustsFontSizeToFit/minimumFontScale, truncation, or a compressed lineHeight?
  • Hiding the UITextView stops VoiceOver from reading the text twice (testSelectionTextViewIsHiddenFromAccessibility), but the existing accessibility elements don't expose the selection. Can a VoiceOver user start, adjust and copy a selection? Can you verify that?

@github-actions

Copy link
Copy Markdown

Visual regression

No visual differences at 089b0b11605c.

android: no capture

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Click and hold to select text using a text component

7 participants