Skip to content

Commit 761a5ff

Browse files
committed
Fix iOS hidden caret Metal errors
Preserve UIKit caret geometry and hide collapsed carets with transparent tint. Restore selection tint when ranges expand, collapse during typing, or move to a new backing view. Exercise the caret transitions in RNTesterUnitTests and run the focused suite in iOS CI. Include the existing Swift import probe in the unit-test target so Xcode links the Swift support libraries required by React Native's dependencies.
1 parent eaf4c26 commit 761a5ff

5 files changed

Lines changed: 190 additions & 5 deletions

File tree

‎.github/workflows/test-all.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,17 @@ jobs:
162162
# test_ios_rntester_dynamic_frameworks).
163163
use-prebuilds: true
164164
flavor: ${{ matrix.flavor }}
165+
- name: Test TextInput caret and selection behavior
166+
if: ${{ matrix.flavor == 'Debug' && matrix.frameworks == false }}
167+
run: |
168+
xcodebuild test \
169+
-workspace packages/rn-tester/RNTesterPods.xcworkspace \
170+
-scheme RNTesterUnitTests \
171+
-configuration Debug \
172+
-sdk iphonesimulator \
173+
-destination 'platform=iOS Simulator,name=iPhone 16' \
174+
-derivedDataPath /tmp/RNTesterBuild \
175+
-only-testing:RNTesterUnitTests/RCTUITextFieldTests
165176
166177
test_maestro_cloud_ios:
167178
needs: test_ios_rntester

‎packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ @implementation RCTUITextField {
2222
NSArray<UIBarButtonItemGroup *> *_initialValueLeadingBarButtonGroups;
2323
NSArray<UIBarButtonItemGroup *> *_initialValueTrailingBarButtonGroups;
2424
NSArray<NSString *> *_acceptDragAndDropTypes;
25+
// Keep the requested tint while a transparent tint hides the caret.
26+
UIColor *_requestedTintColor;
2527
}
2628

2729
// This should not be needed but internal build were failing without it.
@@ -48,6 +50,8 @@ - (instancetype)initWithFrame:(CGRect)frame
4850
- (void)_textDidChange
4951
{
5052
_textWasPasted = NO;
53+
// Typing can collapse a selection without calling setSelectedTextRange:.
54+
[self _updateAppliedTintColor];
5155
}
5256

5357
#pragma mark - Accessibility
@@ -233,13 +237,31 @@ - (void)removeDictationResultPlaceholder:(id)placeholder willInsertResult:(BOOL)
233237

234238
#pragma mark - Caret Manipulation
235239

236-
- (CGRect)caretRectForPosition:(UITextPosition *)position
240+
// Returning CGRectZero for a hidden caret causes oversized Metal surface errors on iOS
241+
// 17. A transparent tint hides the caret without changing its bounds. Restore the tint
242+
// for non-empty selections to preserve the selection highlight and handles.
243+
- (void)setCaretHidden:(BOOL)caretHidden
237244
{
238-
if (_caretHidden) {
239-
return CGRectZero;
245+
if (_caretHidden == caretHidden) {
246+
return;
240247
}
241248

242-
return [super caretRectForPosition:position];
249+
_caretHidden = caretHidden;
250+
[self _updateAppliedTintColor];
251+
}
252+
253+
// Do not override the getter. UIKit reads the applied caret color through `-tintColor`.
254+
- (void)setTintColor:(UIColor *)tintColor
255+
{
256+
_requestedTintColor = tintColor;
257+
[self _updateAppliedTintColor];
258+
}
259+
260+
- (void)_updateAppliedTintColor
261+
{
262+
UITextRange *selectedTextRange = self.selectedTextRange;
263+
BOOL shouldHideCaret = _caretHidden && (selectedTextRange == nil || selectedTextRange.isEmpty);
264+
[super setTintColor:shouldHideCaret ? [UIColor clearColor] : _requestedTintColor];
243265
}
244266

245267
#pragma mark - Positioning Overrides
@@ -262,6 +284,7 @@ - (CGRect)editingRectForBounds:(CGRect)bounds
262284
- (void)setSelectedTextRange:(UITextRange *)selectedTextRange
263285
{
264286
[super setSelectedTextRange:selectedTextRange];
287+
[self _updateAppliedTintColor];
265288
[_textInputDelegateAdapter selectedTextRangeWasSet];
266289
}
267290
#pragma clang diagnostic pop
@@ -275,6 +298,7 @@ - (void)setSelectedTextRange:(UITextRange *)selectedTextRange notifyDelegate:(BO
275298
}
276299

277300
[super setSelectedTextRange:selectedTextRange];
301+
[self _updateAppliedTintColor];
278302
}
279303

280304
- (void)rct_invalidatePendingTextInputEdit

‎packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
323323
_backedTextInputView.defaultTextAttributes = defaultAttributes;
324324
}
325325

326-
if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) {
326+
// A new backing view may have copied a transparent tint from a hidden caret.
327+
if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor ||
328+
newTextInputProps.multiline != oldTextInputProps.multiline) {
327329
_backedTextInputView.tintColor = RCTUIColorFromSharedColor(newTextInputProps.selectionColor);
328330
}
329331

‎packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
#import <React/RCTConversions.h>
9+
#import <React/RCTTextInputComponentView.h>
810
#import <React/RCTUITextField.h>
11+
#import <React/RCTUITextView.h>
912
#import <XCTest/XCTest.h>
13+
#import <react/renderer/components/iostextinput/TextInputProps.h>
14+
#import <react/renderer/core/RawPropsParser.h>
15+
#import <react/renderer/graphics/Color.h>
1016

1117
static NSParagraphStyle *RCTPlaceholderParagraphStyle(RCTUITextField *textField)
1218
{
@@ -54,4 +60,138 @@ - (void)testPlaceholderKeepsParagraphStyleFromTextAttributes
5460
XCTAssertEqual(RCTPlaceholderParagraphStyle(textField).alignment, NSTextAlignmentCenter);
5561
}
5662

63+
- (void)testCaretHiddenMakesTheCaretTransparent
64+
{
65+
RCTUITextField *textField = [RCTUITextField new];
66+
textField.tintColor = UIColor.redColor;
67+
68+
textField.caretHidden = YES;
69+
70+
XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor);
71+
}
72+
73+
- (void)testClearingCaretHiddenRestoresTheSelectionColor
74+
{
75+
RCTUITextField *textField = [RCTUITextField new];
76+
textField.tintColor = UIColor.redColor;
77+
textField.caretHidden = YES;
78+
79+
textField.caretHidden = NO;
80+
81+
XCTAssertEqualObjects(textField.tintColor, UIColor.redColor);
82+
}
83+
84+
- (void)testSelectionColorSetWhileCaretHiddenIsAppliedOnceTheCaretIsShown
85+
{
86+
RCTUITextField *textField = [RCTUITextField new];
87+
textField.caretHidden = YES;
88+
89+
textField.tintColor = UIColor.redColor;
90+
91+
XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor);
92+
93+
textField.caretHidden = NO;
94+
95+
XCTAssertEqualObjects(textField.tintColor, UIColor.redColor);
96+
}
97+
98+
- (void)testSelectionColorIsRestoredForSelectionsAndHiddenAgainWhenCollapsed
99+
{
100+
RCTUITextField *textField = [RCTUITextField new];
101+
textField.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"];
102+
textField.tintColor = UIColor.redColor;
103+
textField.caretHidden = YES;
104+
105+
UITextPosition *start = textField.beginningOfDocument;
106+
UITextPosition *end = [textField positionFromPosition:start offset:textField.attributedText.length];
107+
for (NSNumber *notifyDelegate in @[ @YES, @NO ]) {
108+
[textField setSelectedTextRange:[textField textRangeFromPosition:start toPosition:end]
109+
notifyDelegate:notifyDelegate.boolValue];
110+
XCTAssertEqualObjects(textField.tintColor, UIColor.redColor);
111+
112+
[textField setSelectedTextRange:[textField textRangeFromPosition:end toPosition:end]
113+
notifyDelegate:notifyDelegate.boolValue];
114+
XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor);
115+
}
116+
}
117+
118+
- (void)testReplacingASelectionHidesTheCaretAgain
119+
{
120+
RCTUITextField *textField = [RCTUITextField new];
121+
textField.delegate = nil;
122+
textField.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"];
123+
textField.tintColor = UIColor.redColor;
124+
textField.caretHidden = YES;
125+
[textField setSelectedTextRange:[textField textRangeFromPosition:textField.beginningOfDocument
126+
toPosition:textField.endOfDocument]
127+
notifyDelegate:NO];
128+
XCTAssertEqualObjects(textField.tintColor, UIColor.redColor);
129+
130+
[textField insertText:@"X"];
131+
132+
XCTAssertEqualObjects(textField.attributedText.string, @"X");
133+
XCTAssertTrue(textField.selectedTextRange.isEmpty);
134+
XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor);
135+
}
136+
137+
- (void)testHidingCaretPreservesItsGeometry
138+
{
139+
RCTUITextField *textField = [[RCTUITextField alloc] initWithFrame:CGRectMake(0, 0, 200, 40)];
140+
textField.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"];
141+
[textField layoutIfNeeded];
142+
CGRect visibleCaret = [textField caretRectForPosition:textField.endOfDocument];
143+
XCTAssertGreaterThan(visibleCaret.size.width, 0);
144+
XCTAssertGreaterThan(visibleCaret.size.height, 0);
145+
146+
textField.caretHidden = YES;
147+
148+
XCTAssertTrue(CGRectEqualToRect([textField caretRectForPosition:textField.endOfDocument], visibleCaret));
149+
}
150+
151+
- (void)testClearingSelectionColorRestoresInheritedTint
152+
{
153+
UIView *parent = [UIView new];
154+
parent.tintColor = UIColor.blueColor;
155+
RCTUITextField *textField = [RCTUITextField new];
156+
[parent addSubview:textField];
157+
textField.tintColor = UIColor.redColor;
158+
textField.caretHidden = YES;
159+
160+
textField.tintColor = nil;
161+
XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor);
162+
textField.caretHidden = NO;
163+
XCTAssertEqualObjects(textField.tintColor, UIColor.blueColor);
164+
}
165+
166+
- (void)testSwitchingBackingViewsPreservesSelectionColor
167+
{
168+
using namespace facebook::react;
169+
170+
RCTTextInputComponentView *componentView = [RCTTextInputComponentView new];
171+
ContextContainer contextContainer{};
172+
PropsParserContext context{-1, contextContainer};
173+
RawProps rawProps(folly::dynamic::object("caretHidden", true));
174+
RawPropsParser parser;
175+
parser.prepare<TextInputProps>();
176+
rawProps.parse(parser);
177+
auto singleline = std::make_shared<TextInputProps>(context, TextInputProps{}, rawProps);
178+
singleline->selectionColor = colorFromRGBA(255, 0, 0, 255);
179+
UIColor *selectionColor = RCTUIColorFromSharedColor(singleline->selectionColor);
180+
[componentView updateProps:singleline oldProps:nullptr];
181+
XCTAssertEqualObjects(componentView.subviews.firstObject.tintColor, UIColor.clearColor);
182+
183+
auto multiline = std::make_shared<TextInputProps>(*singleline);
184+
multiline->multiline = true;
185+
[componentView updateProps:multiline oldProps:singleline];
186+
XCTAssertTrue([componentView.subviews.firstObject isKindOfClass:RCTUITextView.class]);
187+
XCTAssertEqualObjects(componentView.subviews.firstObject.tintColor, selectionColor);
188+
189+
[componentView updateProps:singleline oldProps:multiline];
190+
RCTUITextField *textField = (RCTUITextField *)componentView.subviews.firstObject;
191+
XCTAssertTrue([textField isKindOfClass:RCTUITextField.class]);
192+
XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor);
193+
textField.caretHidden = NO;
194+
XCTAssertEqualObjects(textField.tintColor, selectionColor);
195+
}
196+
57197
@end

‎packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
F1A0B1C23D4E5F6071829302 /* RCTTurboModuleArrayBufferTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */; };
5757
F1A0B1C23D4E5F6071829322 /* RCTTurboModuleStringConversionTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F1A0B1C23D4E5F6071829321 /* RCTTurboModuleStringConversionTests.mm */; };
5858
D1A60AD11B9247A080734F02 /* RCTDevLoadingViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */; };
59+
F58158000000000000000002 /* RCTUITextFieldTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F58158000000000000000001 /* RCTUITextFieldTests.mm */; };
60+
F58158000000000000000003 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; };
5961
/* End PBXBuildFile section */
6062

6163
/* Begin PBXContainerItemProxy section */
@@ -163,6 +165,7 @@
163165
F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleArrayBufferTests.mm; sourceTree = "<group>"; };
164166
F1A0B1C23D4E5F6071829321 /* RCTTurboModuleStringConversionTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleStringConversionTests.mm; sourceTree = "<group>"; };
165167
D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTDevLoadingViewTests.m; sourceTree = "<group>"; };
168+
F58158000000000000000001 /* RCTUITextFieldTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTUITextFieldTests.mm; path = "../../react-native/React/Tests/Text/RCTUITextFieldTests.mm"; sourceTree = "<group>"; };
166169
/* End PBXFileReference section */
167170

168171
/* Begin PBXFrameworksBuildPhase section */
@@ -346,6 +349,7 @@
346349
A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */,
347350
E7DB20BE22B2BAA4005AC45F /* RCTNativeAnimatedNodesManagerTests.m */,
348351
E7DB20AD22B2BAA3005AC45F /* RCTPerformanceLoggerTests.m */,
352+
F58158000000000000000001 /* RCTUITextFieldTests.mm */,
349353
E7DB20C122B2BAA4005AC45F /* RCTUnicodeDecodeTests.m */,
350354
E7DB20D022B2BAA5005AC45F /* RCTURLUtilsTests.m */,
351355
D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */,
@@ -749,6 +753,8 @@
749753
isa = PBXSourcesBuildPhase;
750754
buildActionMask = 2147483647;
751755
files = (
756+
F58158000000000000000002 /* RCTUITextFieldTests.mm in Sources */,
757+
F58158000000000000000003 /* SwiftTest.swift in Sources */,
752758
A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */,
753759
E7DB20DF22B2BAA6005AC45F /* RCTImageLoaderTests.m in Sources */,
754760
E7DB20D522B2BAA6005AC45F /* RCTPerformanceLoggerTests.m in Sources */,
@@ -1109,6 +1115,7 @@
11091115
OTHER_SWIFT_FLAGS = "$(inherited)";
11101116
PRODUCT_BUNDLE_IDENTIFIER = com.meta.RNTesterUnitTests;
11111117
PRODUCT_NAME = "$(TARGET_NAME)";
1118+
SWIFT_VERSION = 5.0;
11121119
TARGETED_DEVICE_FAMILY = "1,2";
11131120
};
11141121
name = Debug;
@@ -1147,6 +1154,7 @@
11471154
OTHER_SWIFT_FLAGS = "$(inherited)";
11481155
PRODUCT_BUNDLE_IDENTIFIER = com.meta.RNTesterUnitTests;
11491156
PRODUCT_NAME = "$(TARGET_NAME)";
1157+
SWIFT_VERSION = 5.0;
11501158
TARGETED_DEVICE_FAMILY = "1,2";
11511159
};
11521160
name = Release;

0 commit comments

Comments
 (0)