Skip to content

Commit 86c90d9

Browse files
NickGerlemanfacebook-github-bot
authored andcommitted
Fix cursor moving while typing quickly and autocorrection triggered in controlled single line TextInput on iOS (New Arch) (facebook#46970)
Summary: Fixes facebook#44157 This one is a bit of a doozy... During auto-correct in UITextField (used for single line TextInput) iOS will mutate the buffer in two parts, non-atomically. After the first part, after iOS triggers `textFieldDidChange`, selection is in the wrong position. If we set full new AttributedText at this point, we propagate the incorrect cursor position, and it is never restored. In the common case, where we are not mutating text in the controlled component, we shouldn't need to be setting AttributedString in the first place, and we do have an equality comparison there currently. But it is defeated because attributes are not identical. There are a few sources of that: 1. NSParagraphStyle is present in backing input, but not the AttributedString we are setting. 2. Backing text has an NSShadow with no color (does not render) not in the AttributedText 3. Event emitter attributes change on each update, and new text does not inherit the attributes. The first two are part of the backing input `typingAttributes`, even if we set a dictionary without them. To solve for them, we make attribute comparison insensitive to the attribute values in a default initialized control. There is code around here fully falling back to attribute insensitive comparison, which we would ideally fix to instead role into this "effective" attribute comparison. The event emitter attributes being misaligned is a real problem. We fix in a couple ways. 1. We treat the attribute values as equal if the backing event emitter is the same 2. We set paragraph level event emitter as a default attribute so the first typed character receives it After these fixes, scenario in facebook/react-native-website#4247 no longer repros in new arch. Typing in debug build also subjectively seems faster? (we are not doing second invalidation of the control on every keypress). Changes which do mutate content may be susceptible to the same style of issue, though on web/`react-dom` in Chrome, this seems to not try to preserve selection at all if the selection is uncontrolled, so this seems like less of an issue. I haven't yet looked at old arch, but my guess is we have similar issues there, and could be fixed in similar ways (though, we've been trying to avoid changing it as much as possible, and 0.76+ has new arch as default, so not sure if worth fixing in old impl as well if this is very long running issue). Changelog: [iOS][Fixed] - Fix cursor moving in iOS controlled single line TextInput on Autocorrection (New Arch) Differential Revision: D64121570
1 parent 0902b0a commit 86c90d9

File tree

5 files changed

+216
-19
lines changed

5 files changed

+216
-19
lines changed

packages/react-native/Libraries/Text/TextInput/RCTBackedTextInputViewProtocol.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ NS_ASSUME_NONNULL_BEGIN
3535
@property (nonatomic, assign, readonly) CGFloat zoomScale;
3636
@property (nonatomic, assign, readonly) CGPoint contentOffset;
3737
@property (nonatomic, assign, readonly) UIEdgeInsets contentInset;
38+
@property (nullable, nonatomic, copy) NSDictionary<NSAttributedStringKey, id> *typingAttributes;
3839

3940
// This protocol disallows direct access to `selectedTextRange` property because
4041
// unwise usage of it can break the `delegate` behavior. So, we always have to

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
#import <react/renderer/components/iostextinput/TextInputComponentDescriptor.h>
1111
#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
12+
#import <react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h>
1213
#import <react/renderer/textlayoutmanager/TextLayoutManager.h>
1314

1415
#import <React/RCTBackedTextInputViewProtocol.h>
@@ -61,6 +62,13 @@ @implementation RCTTextInputComponentView {
6162
*/
6263
BOOL _comingFromJS;
6364
BOOL _didMoveToWindow;
65+
66+
/*
67+
* Newly initialized default typing attributes contain a no-op NSParagraphStyle and NSShadow. These cause inequality
68+
* between the AttributedString backing the input and those generated from state. We store these attributes to make
69+
* later comparison insensitive to them.
70+
*/
71+
NSDictionary<NSAttributedStringKey, id> *_originalTypingAttributes;
6472
}
6573

6674
#pragma mark - UIView overrides
@@ -79,11 +87,26 @@ - (instancetype)initWithFrame:(CGRect)frame
7987

8088
[self addSubview:_backedTextInputView];
8189
[self initializeReturnKeyType];
90+
91+
_originalTypingAttributes = [_backedTextInputView.typingAttributes copy];
8292
}
8393

8494
return self;
8595
}
8696

97+
- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter
98+
{
99+
[super updateEventEmitter:eventEmitter];
100+
101+
NSMutableDictionary<NSAttributedStringKey, id> *defaultAttributes = [_backedTextInputView.defaultTextAttributes mutableCopy];
102+
103+
RCTWeakEventEmitterWrapper *eventEmitterWrapper = [RCTWeakEventEmitterWrapper new];
104+
eventEmitterWrapper.eventEmitter = _eventEmitter;
105+
defaultAttributes[RCTAttributedStringEventEmitterKey] = eventEmitterWrapper;
106+
107+
_backedTextInputView.defaultTextAttributes = defaultAttributes;
108+
}
109+
87110
- (void)didMoveToWindow
88111
{
89112
[super didMoveToWindow];
@@ -236,8 +259,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
236259
}
237260

238261
if (newTextInputProps.textAttributes != oldTextInputProps.textAttributes) {
239-
_backedTextInputView.defaultTextAttributes =
262+
NSMutableDictionary<NSAttributedStringKey, id> *defaultAttributes =
240263
RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier()));
264+
defaultAttributes[RCTAttributedStringEventEmitterKey] =
265+
_backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey];
266+
_backedTextInputView.defaultTextAttributes = defaultAttributes;
241267
}
242268

243269
if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) {
@@ -732,9 +758,10 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
732758
_backedTextInputView.markedTextRange || _backedTextInputView.isSecureTextEntry || fontHasBeenUpdatedBySystem;
733759

734760
if (shouldFallbackToBareTextComparison) {
735-
return ([newText.string isEqualToString:oldText.string]);
761+
return [newText.string isEqualToString:oldText.string];
736762
} else {
737-
return ([newText isEqualToAttributedString:oldText]);
763+
return RCTIsAttributedStringEffectivelySame(
764+
newText, oldText, _originalTypingAttributes, static_cast<const TextInputProps &>(*_props).textAttributes);
738765
}
739766
}
740767

packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ NSString *const RCTTextAttributesAccessibilityRoleAttributeName = @"Accessibilit
2222
/*
2323
* Creates `NSTextAttributes` from given `facebook::react::TextAttributes`
2424
*/
25-
NSDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(
25+
NSMutableDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(
2626
const facebook::react::TextAttributes &textAttributes);
2727

2828
/*
@@ -41,6 +41,17 @@ NSString *RCTNSStringFromStringApplyingTextTransform(NSString *string, facebook:
4141

4242
void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText);
4343

44+
/*
45+
* Whether two `NSAttributedString` lead to the same underlying displayed text, even if they are not strictly equal.
46+
* I.e. is one string substitutable for the other when backing a control (which may have some ignorable attributes
47+
* provided).
48+
*/
49+
BOOL RCTIsAttributedStringEffectivelySame(
50+
NSAttributedString *text1,
51+
NSAttributedString *text2,
52+
NSDictionary<NSAttributedStringKey, id> *insensitiveAttributes,
53+
const facebook::react::TextAttributes &textAttributes);
54+
4455
@interface RCTWeakEventEmitterWrapper : NSObject
4556
@property (nonatomic, assign) facebook::react::SharedEventEmitter eventEmitter;
4657
@end

packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ - (void)dealloc
3535
_weakEventEmitter.reset();
3636
}
3737

38+
- (BOOL)isEqual:(id)object
39+
{
40+
// We consider the underlying EventEmitter as the identity
41+
if (![object isKindOfClass:[self class]]) {
42+
return NO;
43+
}
44+
45+
auto thisEventEmitter = [self eventEmitter];
46+
auto otherEventEmitter = [((RCTWeakEventEmitterWrapper *)object) eventEmitter];
47+
return thisEventEmitter == otherEventEmitter;
48+
}
49+
50+
- (NSUInteger)hash
51+
{
52+
// We consider the underlying EventEmitter as the identity
53+
return (NSUInteger)_weakEventEmitter.lock().get();
54+
}
55+
3856
@end
3957

4058
inline static UIFontWeight RCTUIFontWeightFromInteger(NSInteger fontWeight)
@@ -156,7 +174,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
156174

157175
inline static UIColor *RCTEffectiveForegroundColorFromTextAttributes(const TextAttributes &textAttributes)
158176
{
159-
UIColor *effectiveForegroundColor = RCTUIColorFromSharedColor(textAttributes.foregroundColor) ?: [UIColor blackColor];
177+
UIColor *effectiveForegroundColor =
178+
RCTPlatformColorFromColor(*textAttributes.foregroundColor) ?: [UIColor blackColor];
160179

161180
if (!isnan(textAttributes.opacity)) {
162181
effectiveForegroundColor = [effectiveForegroundColor
@@ -168,7 +187,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
168187

169188
inline static UIColor *RCTEffectiveBackgroundColorFromTextAttributes(const TextAttributes &textAttributes)
170189
{
171-
UIColor *effectiveBackgroundColor = RCTUIColorFromSharedColor(textAttributes.backgroundColor);
190+
UIColor *effectiveBackgroundColor = RCTPlatformColorFromColor(*textAttributes.backgroundColor);
172191

173192
if (effectiveBackgroundColor && !isnan(textAttributes.opacity)) {
174193
effectiveBackgroundColor = [effectiveBackgroundColor
@@ -178,7 +197,8 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
178197
return effectiveBackgroundColor ?: [UIColor clearColor];
179198
}
180199

181-
NSDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(const TextAttributes &textAttributes)
200+
NSMutableDictionary<NSAttributedStringKey, id> *RCTNSTextAttributesFromTextAttributes(
201+
const TextAttributes &textAttributes)
182202
{
183203
NSMutableDictionary<NSAttributedStringKey, id> *attributes = [NSMutableDictionary dictionaryWithCapacity:10];
184204

@@ -256,7 +276,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
256276
NSUnderlineStyle style = RCTNSUnderlineStyleFromTextDecorationStyle(
257277
textAttributes.textDecorationStyle.value_or(TextDecorationStyle::Solid));
258278

259-
UIColor *textDecorationColor = RCTUIColorFromSharedColor(textAttributes.textDecorationColor);
279+
UIColor *textDecorationColor = RCTPlatformColorFromColor(*textAttributes.textDecorationColor);
260280

261281
// Underline
262282
if (textDecorationLineType == TextDecorationLineType::Underline ||
@@ -285,7 +305,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
285305
NSShadow *shadow = [NSShadow new];
286306
shadow.shadowOffset = CGSize{textShadowOffset.width, textShadowOffset.height};
287307
shadow.shadowBlurRadius = textAttributes.textShadowRadius;
288-
shadow.shadowColor = RCTUIColorFromSharedColor(textAttributes.textShadowColor);
308+
shadow.shadowColor = RCTPlatformColorFromColor(*textAttributes.textShadowColor);
289309
attributes[NSShadowAttributeName] = shadow;
290310
}
291311

@@ -302,7 +322,7 @@ inline static CGFloat RCTEffectiveFontSizeMultiplierFromTextAttributes(const Tex
302322
attributes[RCTTextAttributesAccessibilityRoleAttributeName] = [NSString stringWithUTF8String:roleStr.c_str()];
303323
}
304324

305-
return [attributes copy];
325+
return attributes;
306326
}
307327

308328
void RCTApplyBaselineOffset(NSMutableAttributedString *attributedText)
@@ -466,3 +486,147 @@ AttributedStringBox RCTAttributedStringBoxFromNSAttributedString(NSAttributedStr
466486
return string;
467487
}
468488
}
489+
490+
static BOOL RCTIsParagraphStyleEffectivelySame(
491+
NSParagraphStyle *style1,
492+
NSParagraphStyle *style2,
493+
const TextAttributes &baseTextAttributes)
494+
{
495+
if (style1 == nil || style2 == nil) {
496+
return style1 == nil && style2 == nil;
497+
}
498+
499+
// The NSParagraphStyle included as part of typingAttributes may eventually resolve "natural" directions to
500+
// physical direction, so we should compare resolved directions
501+
auto naturalAlignment =
502+
baseTextAttributes.layoutDirection.value_or(LayoutDirection::LeftToRight) == LayoutDirection::LeftToRight
503+
? NSTextAlignmentLeft
504+
: NSTextAlignmentRight;
505+
506+
NSWritingDirection naturalBaseWritingDirection = baseTextAttributes.baseWritingDirection.has_value()
507+
? RCTNSWritingDirectionFromWritingDirection(baseTextAttributes.baseWritingDirection.value())
508+
: [NSParagraphStyle defaultWritingDirectionForLanguage:nil];
509+
510+
if (style1.alignment == NSTextAlignmentNatural || style1.baseWritingDirection == NSWritingDirectionNatural) {
511+
NSMutableParagraphStyle *mutableStyle1 = [style1 mutableCopy];
512+
style1 = mutableStyle1;
513+
514+
if (mutableStyle1.alignment == NSTextAlignmentNatural) {
515+
mutableStyle1.alignment = naturalAlignment;
516+
}
517+
518+
if (mutableStyle1.baseWritingDirection == NSWritingDirectionNatural) {
519+
mutableStyle1.baseWritingDirection = naturalBaseWritingDirection;
520+
}
521+
}
522+
523+
if (style2.alignment == NSTextAlignmentNatural || style2.baseWritingDirection == NSWritingDirectionNatural) {
524+
NSMutableParagraphStyle *mutableStyle2 = [style2 mutableCopy];
525+
style2 = mutableStyle2;
526+
527+
if (mutableStyle2.alignment == NSTextAlignmentNatural) {
528+
mutableStyle2.alignment = naturalAlignment;
529+
}
530+
531+
if (mutableStyle2.baseWritingDirection == NSWritingDirectionNatural) {
532+
mutableStyle2.baseWritingDirection = naturalBaseWritingDirection;
533+
}
534+
}
535+
536+
return [style1 isEqual:style2];
537+
}
538+
539+
static BOOL RCTIsAttributeEffectivelySame(
540+
NSAttributedStringKey attributeKey,
541+
NSDictionary<NSAttributedStringKey, id> *attributes1,
542+
NSDictionary<NSAttributedStringKey, id> *attributes2,
543+
NSDictionary<NSAttributedStringKey, id> *insensitiveAttributes,
544+
const TextAttributes &baseTextAttributes)
545+
{
546+
id attribute1 = attributes1[attributeKey] ?: insensitiveAttributes[attributeKey];
547+
id attribute2 = attributes2[attributeKey] ?: insensitiveAttributes[attributeKey];
548+
549+
// Normalize attributes which can inexact but still effectively the same
550+
if (attributeKey == NSParagraphStyleAttributeName) {
551+
return RCTIsParagraphStyleEffectivelySame(attribute1, attribute2, baseTextAttributes);
552+
}
553+
554+
// Otherwise rely on built-in comparison
555+
return [attribute1 isEqual:attribute2];
556+
}
557+
558+
BOOL RCTIsAttributedStringEffectivelySame(
559+
NSAttributedString *text1,
560+
NSAttributedString *text2,
561+
NSDictionary<NSAttributedStringKey, id> *insensitiveAttributes,
562+
const TextAttributes &baseTextAttributes)
563+
{
564+
if (![text1.string isEqualToString:text2.string]) {
565+
return NO;
566+
}
567+
568+
// We check that for every fragment in the old string
569+
// 1. The new string's fragment overlapping the first spans the same characters
570+
// 2. The attributes of each matching fragment are the same, ignoring those which match insensitive attibutes
571+
__block BOOL areAttributesSame = YES;
572+
[text1 enumerateAttributesInRange:NSMakeRange(0, text1.length)
573+
options:0
574+
usingBlock:^(
575+
NSDictionary<NSAttributedStringKey, id> *text1Attributes,
576+
NSRange text1Range,
577+
BOOL *text1Stop) {
578+
[text2 enumerateAttributesInRange:text1Range
579+
options:0
580+
usingBlock:^(
581+
NSDictionary<NSAttributedStringKey, id> *text2Attributes,
582+
NSRange text2Range,
583+
BOOL *text2Stop) {
584+
if (!NSEqualRanges(text1Range, text2Range)) {
585+
areAttributesSame = NO;
586+
*text1Stop = YES;
587+
*text2Stop = YES;
588+
return;
589+
}
590+
591+
// Compare every attribute in text1 to the corresponding attribute
592+
// in text2, or the set of insensitive attributes if not present
593+
for (NSAttributedStringKey key in text1Attributes) {
594+
if (!RCTIsAttributeEffectivelySame(
595+
key,
596+
text1Attributes,
597+
text2Attributes,
598+
insensitiveAttributes,
599+
baseTextAttributes)) {
600+
areAttributesSame = NO;
601+
*text1Stop = YES;
602+
*text2Stop = YES;
603+
return;
604+
}
605+
}
606+
607+
for (NSAttributedStringKey key in text2Attributes) {
608+
// We have already compared this attribute if it is present in
609+
// both
610+
if (text1Attributes[key] != nil) {
611+
continue;
612+
}
613+
614+
// But we still need to compare attributes if it is only present
615+
// in text 2, to compare against insensitive attributes
616+
if (!RCTIsAttributeEffectivelySame(
617+
key,
618+
text1Attributes,
619+
text2Attributes,
620+
insensitiveAttributes,
621+
baseTextAttributes)) {
622+
areAttributesSame = NO;
623+
*text1Stop = YES;
624+
*text2Stop = YES;
625+
return;
626+
}
627+
}
628+
}];
629+
}];
630+
631+
return areAttributesSame;
632+
}

packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextPrimitivesConversions.h

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77

88
#import <UIKit/UIKit.h>
99

10-
#include <react/renderer/graphics/RCTPlatformColorUtils.h>
11-
#include <react/renderer/textlayoutmanager/RCTFontProperties.h>
12-
#include <react/renderer/textlayoutmanager/RCTFontUtils.h>
10+
#import <react/renderer/graphics/RCTPlatformColorUtils.h>
11+
#import <react/renderer/textlayoutmanager/RCTFontProperties.h>
12+
#import <react/renderer/textlayoutmanager/RCTFontUtils.h>
1313

1414
inline static NSTextAlignment RCTNSTextAlignmentFromTextAlignment(facebook::react::TextAlignment textAlignment)
1515
{
@@ -112,9 +112,3 @@ inline static NSUnderlineStyle RCTNSUnderlineStyleFromTextDecorationStyle(
112112
return NSUnderlinePatternDot | NSUnderlineStyleSingle;
113113
}
114114
}
115-
116-
// TODO: this file has some duplicates method, we can remove it
117-
inline static UIColor *_Nullable RCTUIColorFromSharedColor(const facebook::react::SharedColor &sharedColor)
118-
{
119-
return RCTPlatformColorFromColor(*sharedColor);
120-
}

0 commit comments

Comments
 (0)