From 282baa0882968223b81dc4af563e2e626202752d Mon Sep 17 00:00:00 2001 From: levibuzolic Date: Thu, 27 Aug 2026 12:50:50 +1000 Subject: [PATCH] Fix iOS hidden caret Metal errors --- .../TextInput/Singleline/RCTUITextField.mm | 30 +++++++-- .../TextInput/RCTTextInputComponentView.mm | 4 ++ .../React/Tests/Text/RCTUITextFieldTests.mm | 65 +++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index 064b48bb3720..b812e1264f06 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -17,6 +17,8 @@ @implementation RCTUITextField { NSArray *_initialValueLeadingBarButtonGroups; NSArray *_initialValueTrailingBarButtonGroups; NSArray *_acceptDragAndDropTypes; + // Keep the requested tint while a transparent tint hides the caret. + UIColor *_requestedTintColor; } // This should not be needed but internal build were failing without it. @@ -210,13 +212,31 @@ - (void)removeDictationResultPlaceholder:(id)placeholder willInsertResult:(BOOL) #pragma mark - Caret Manipulation -- (CGRect)caretRectForPosition:(UITextPosition *)position +// Returning CGRectZero for a hidden caret causes oversized Metal surface errors on iOS +// 17. A transparent tint hides the caret without changing its bounds. Restore the tint +// for non-empty selections to preserve the selection highlight and handles. +- (void)setCaretHidden:(BOOL)caretHidden { - if (_caretHidden) { - return CGRectZero; + if (_caretHidden == caretHidden) { + return; } - return [super caretRectForPosition:position]; + _caretHidden = caretHidden; + [self _updateAppliedTintColor]; +} + +// Do not override the getter. UIKit reads the applied caret color through `-tintColor`. +- (void)setTintColor:(UIColor *)tintColor +{ + _requestedTintColor = tintColor; + [self _updateAppliedTintColor]; +} + +- (void)_updateAppliedTintColor +{ + UITextRange *selectedTextRange = self.selectedTextRange; + BOOL shouldHideCaret = _caretHidden && (selectedTextRange == nil || selectedTextRange.isEmpty); + [super setTintColor:shouldHideCaret ? [UIColor clearColor] : _requestedTintColor]; } #pragma mark - Positioning Overrides @@ -239,6 +259,7 @@ - (CGRect)editingRectForBounds:(CGRect)bounds - (void)setSelectedTextRange:(UITextRange *)selectedTextRange { [super setSelectedTextRange:selectedTextRange]; + [self _updateAppliedTintColor]; [_textInputDelegateAdapter selectedTextRangeWasSet]; } #pragma clang diagnostic pop @@ -252,6 +273,7 @@ - (void)setSelectedTextRange:(UITextRange *)selectedTextRange notifyDelegate:(BO } [super setSelectedTextRange:selectedTextRange]; + [self _updateAppliedTintColor]; } - (void)scrollRangeToVisible:(NSRange)range diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 8f55121cd23d..a41494576c02 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -849,6 +849,10 @@ - (void)_setMultiline:(BOOL)multiline UIView *backedTextInputView = multiline ? [RCTUITextView new] : [RCTUITextField new]; backedTextInputView.frame = _backedTextInputView.frame; RCTCopyBackedTextInput(_backedTextInputView, backedTextInputView); + // The copied tint can be transparent when the source hides its caret. Restore the + // selection color from props after changing the input type. + backedTextInputView.tintColor = + RCTUIColorFromSharedColor(static_cast(*_props).selectionColor); _backedTextInputView = backedTextInputView; [self addSubview:_backedTextInputView]; } diff --git a/packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm b/packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm new file mode 100644 index 000000000000..edf1d6c7d2d9 --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTUITextFieldTests.mm @@ -0,0 +1,65 @@ +/* + * 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 + +@interface RCTUITextFieldTests : XCTestCase +@end + +@implementation RCTUITextFieldTests + +- (void)testCaretHiddenMakesTheCaretTransparent +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.tintColor = UIColor.redColor; + + textField.caretHidden = YES; + + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); +} + +- (void)testClearingCaretHiddenRestoresTheSelectionColor +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.tintColor = UIColor.redColor; + textField.caretHidden = YES; + + textField.caretHidden = NO; + + XCTAssertEqualObjects(textField.tintColor, UIColor.redColor); +} + +- (void)testSelectionColorSetWhileCaretHiddenIsAppliedOnceTheCaretIsShown +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.caretHidden = YES; + + textField.tintColor = UIColor.redColor; + + XCTAssertEqualObjects(textField.tintColor, UIColor.clearColor); + + textField.caretHidden = NO; + + XCTAssertEqualObjects(textField.tintColor, UIColor.redColor); +} + +- (void)testSelectionColorIsAppliedToANonEmptySelection +{ + RCTUITextField *textField = [RCTUITextField new]; + textField.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; + textField.tintColor = UIColor.redColor; + textField.caretHidden = YES; + + UITextPosition *start = textField.beginningOfDocument; + UITextPosition *end = [textField positionFromPosition:start offset:textField.attributedText.length]; + [textField setSelectedTextRange:[textField textRangeFromPosition:start toPosition:end] notifyDelegate:NO]; + + XCTAssertEqualObjects(textField.tintColor, UIColor.redColor); +} + +@end