From a91468ffe62122bc49d964a08b493374f0af4775 Mon Sep 17 00:00:00 2001 From: Michael Jerris Date: Sun, 18 Oct 2026 07:09:20 -0400 Subject: [PATCH 1/2] Don't write a TextInput's own text back into the focused field (iOS, Fabric) On iOS with the New Architecture, typing fast into a TextInput can lose keys (#58904). When a state comes back with the current event count, updateState:oldState: writes its text into the field if -_textOf:equals: reports a difference. Written into a focused field, the write drops keys: UIKit delivers the keys queued behind -setAttributedText: inside it, -textInputDidChange ignores them (_comingFromJS) and the write replaces them. Many of these writes change nothing the field shows, but RCTIsAttributedStringEffectivelySame reports a difference because UIKit decorates typed text: the input's default text attributes (background color, event emitter) on the first character, a paragraph style (UIKit's default one, or the one in its initial typing attributes, which has another line break strategy), and in a single-line UITextField its own line break mode and writing direction and no line height (`lineHeight`, `writingDirection`). A controlled `value` built from props carries none of them. - RCTIsAttributedStringEffectivelySame gets a `singleLine` argument (new 5-argument overload; the 4-argument function stays, unchanged): for a single-line field it does not compare the line break mode, writing direction and line height. A multiline input still compares the line break mode (a text view keeps the one in its text: a JS `lineBreakModeIOS` change is written). - -_textOf:equals: passes the input's defaultTextAttributes and UIKit's default paragraph style as insensitive attributes, and whether the input is single-line. It also accepts what it accepted before (_originalTypingAttributes as insensitive attributes), so it never writes where it did not before. Tests: RCTAttributedTextUtilsTest (comparison), and RCTTextInputComponentViewTests, which commits states through updateState:oldState: into a backed text input that delivers keys inside -setAttributedText: as UIKit does; its no-op cases lose the keys on main. RNTester: TextInput > "Typing round trip". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N7ztWHg5Dnq1KeknJQnfex --- .../TextInput/RCTTextInputComponentView.mm | 15 +- .../Tests/Text/RCTAttributedTextUtilsTest.mm | 172 ++++++++ .../Text/RCTTextInputComponentViewTests.mm | 384 ++++++++++++++++++ .../RCTAttributedTextUtils.h | 12 + .../RCTAttributedTextUtils.mm | 37 +- .../TextInput/TextInputExample.ios.js | 61 +++ 6 files changed, 674 insertions(+), 7 deletions(-) create mode 100644 packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm 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 34dfa8f1c5fa..ca06968df602 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -900,8 +900,21 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe if (shouldFallbackToBareTextComparison) { return [newText.string isEqualToString:oldText.string]; } else { + // Typed text carries attributes a state built from props (a controlled `value`) does not: the input's + // `defaultTextAttributes` (the first character typed into an empty field also gets their background color and + // event emitter) and a paragraph style, which is UIKit's default one or the one in its initial typing attributes + // (another line break strategy). Both are accepted. + const auto &props = static_cast(*_props); + NSMutableDictionary *defaultAttributes = + [_originalTypingAttributes mutableCopy] ?: [NSMutableDictionary new]; + [defaultAttributes addEntriesFromDictionary:_backedTextInputView.defaultTextAttributes]; + defaultAttributes[NSParagraphStyleAttributeName] = + _backedTextInputView.defaultTextAttributes[NSParagraphStyleAttributeName] + ?: NSParagraphStyle.defaultParagraphStyle; return RCTIsAttributedStringEffectivelySame( - newText, oldText, _originalTypingAttributes, static_cast(*_props).textAttributes); + newText, oldText, defaultAttributes, props.textAttributes, !props.multiline) || + RCTIsAttributedStringEffectivelySame( + newText, oldText, _originalTypingAttributes, props.textAttributes, !props.multiline); } } diff --git a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm index a02622d5d7d7..5a7a7d23cd50 100644 --- a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm +++ b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm @@ -386,6 +386,178 @@ - (void)testDifferenceOfMissingParagraphStyle attributedString2, attributedString1, insensitiveAttributes, textAttributes)); } +#pragma mark - RCTIsAttributedStringEffectivelySame for the text a text input shows + +static NSDictionary *RCTTestTextAttributes(void) +{ + return @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSForegroundColorAttributeName : UIColor.blackColor, + }; +} + +// The insensitive attributes of a text input: its default text attributes and UIKit's default paragraph style. +static NSDictionary *RCTTestInsensitiveAttributes(void) +{ + NSMutableDictionary *attributes = [RCTTestTextAttributes() mutableCopy]; + attributes[NSBackgroundColorAttributeName] = UIColor.clearColor; + attributes[NSParagraphStyleAttributeName] = NSParagraphStyle.defaultParagraphStyle; + return attributes; +} + +// What UIKit gives text typed into a text field whose text attributes set no paragraph style. +static NSParagraphStyle *RCTTestTypedParagraphStyle(void) +{ + NSMutableParagraphStyle *style = [NSParagraphStyle.defaultParagraphStyle mutableCopy]; + style.lineBreakMode = NSLineBreakByTruncatingTail; + style.baseWritingDirection = NSWritingDirectionLeftToRight; + return style; +} + +static NSAttributedString *RCTTestString(NSString *string, NSParagraphStyle *_Nullable style) +{ + NSMutableDictionary *attributes = [RCTTestTextAttributes() mutableCopy]; + attributes[NSParagraphStyleAttributeName] = style; + return [[NSAttributedString alloc] initWithString:string attributes:attributes]; +} + +// Whether a text input showing `shown` already shows `state`, both ways round. +static BOOL +RCTTestShowsBothWays(XCTestCase *self, NSAttributedString *shown, NSAttributedString *state, BOOL singleLine) +{ + BOOL same = + RCTIsAttributedStringEffectivelySame(state, shown, RCTTestInsensitiveAttributes(), TextAttributes{}, singleLine); + BOOL reversed = + RCTIsAttributedStringEffectivelySame(shown, state, RCTTestInsensitiveAttributes(), TextAttributes{}, singleLine); + XCTAssertEqual(same, reversed); + return same; +} + +#define RCTTestShows(shown, state, singleLine) RCTTestShowsBothWays(self, shown, state, singleLine) + +- (void)testSamenessOfTypedTextWithUIKitsDefaultParagraphStyle +{ + // The state built from a controlled `value` has no paragraph style; the typed text has UIKit's default one. + NSAttributedString *state = RCTTestString(@"abc", nil); + NSAttributedString *shown = RCTTestString(@"abc", RCTTestTypedParagraphStyle()); + + XCTAssertTrue(RCTTestShows(shown, state, YES)); + // A text view does not set its own line break mode: another one in a multiline input is a change. + XCTAssertFalse(RCTTestShows(shown, state, NO)); +} + +- (void)testSamenessOfTypedTextWithTheInputsDefaultAttributes +{ + // The first character typed into an empty field gets the field's default attributes after it reached JS. + NSMutableAttributedString *shown = [RCTTestString(@"/", RCTTestTypedParagraphStyle()) mutableCopy]; + [shown addAttribute:NSBackgroundColorAttributeName value:UIColor.clearColor range:NSMakeRange(0, 1)]; + + XCTAssertTrue(RCTTestShows(shown, RCTTestString(@"/", nil), YES)); +} + +- (void)testSamenessOfAnyLineBreakModeInASingleLineField +{ + NSMutableParagraphStyle *wrapping = [NSMutableParagraphStyle new]; + wrapping.lineBreakMode = NSLineBreakByWordWrapping; + NSMutableParagraphStyle *truncating = [NSMutableParagraphStyle new]; + truncating.lineBreakMode = NSLineBreakByTruncatingTail; + + XCTAssertTrue(RCTTestShows(RCTTestString(@"abc", truncating), RCTTestString(@"abc", wrapping), YES)); + // A multiline input shows the line break mode JS sets (`lineBreakModeIOS`). + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", truncating), RCTTestString(@"abc", wrapping), NO)); +} + +- (void)testSamenessOfASingleLineFieldWithoutTheLineHeight +{ + // `lineHeight` on a single-line input: the text field shows the text without the line height, with the writing + // direction resolved. + NSMutableParagraphStyle *stateStyle = [NSMutableParagraphStyle new]; + stateStyle.minimumLineHeight = 26; + stateStyle.maximumLineHeight = 26; + NSMutableParagraphStyle *shownStyle = [RCTTestTypedParagraphStyle() mutableCopy]; + + XCTAssertTrue(RCTTestShows(RCTTestString(@"p", shownStyle), RCTTestString(@"p", stateStyle), YES)); + // A multiline input keeps the line height. + XCTAssertFalse(RCTTestShows(RCTTestString(@"p", shownStyle), RCTTestString(@"p", stateStyle), NO)); +} + +- (void)testDifferenceOfAnotherLineHeightInAMultilineInput +{ + NSMutableParagraphStyle *stateStyle = [NSMutableParagraphStyle new]; + stateStyle.minimumLineHeight = 30; + stateStyle.maximumLineHeight = 30; + NSMutableParagraphStyle *shownStyle = [stateStyle mutableCopy]; + shownStyle.minimumLineHeight = 26; + shownStyle.maximumLineHeight = 26; + + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", shownStyle), RCTTestString(@"abc", stateStyle), NO)); +} + +- (void)testDifferenceOfAnotherAlignment +{ + NSMutableParagraphStyle *centered = [NSMutableParagraphStyle new]; + centered.alignment = NSTextAlignmentCenter; + + XCTAssertFalse( + RCTTestShows(RCTTestString(@"abc", RCTTestTypedParagraphStyle()), RCTTestString(@"abc", centered), YES)); + // Shown centered while the state sets no paragraph style: a change too. + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", centered), RCTTestString(@"abc", nil), YES)); +} + +- (void)testDifferenceOfAChangedOrRemovedAttribute +{ + NSMutableAttributedString *restyled = [RCTTestString(@"abc", nil) mutableCopy]; + [restyled addAttribute:NSForegroundColorAttributeName value:UIColor.redColor range:NSMakeRange(0, 3)]; + XCTAssertFalse(RCTTestShows(RCTTestString(@"abc", RCTTestTypedParagraphStyle()), restyled, YES)); + + // JS removed the underline the field shows: not one of the input's default attributes. + NSMutableAttributedString *underlined = [RCTTestString(@"abc", nil) mutableCopy]; + [underlined addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:NSMakeRange(0, 3)]; + XCTAssertFalse(RCTTestShows(underlined, RCTTestString(@"abc", nil), YES)); +} + +- (void)testSamenessAndDifferenceOfLetterSpacing +{ + NSMutableAttributedString *state = [RCTTestString(@"abc", nil) mutableCopy]; + [state addAttribute:NSKernAttributeName value:@3 range:NSMakeRange(0, 3)]; + NSMutableAttributedString *shown = [state mutableCopy]; + [shown addAttribute:NSParagraphStyleAttributeName value:RCTTestTypedParagraphStyle() range:NSMakeRange(0, 3)]; + + XCTAssertTrue(RCTTestShows(shown, state, YES)); + // JS removing the letter spacing is a change. + XCTAssertFalse(RCTTestShows(shown, RCTTestString(@"abc", nil), YES)); +} + +- (void)testSamenessOfRightToLeftTextInASingleLineField +{ + // A text field shows the text with its own writing direction, whatever the state's paragraph style sets. + NSString *hebrew = @"\u05E9\u05DC\u05D5\u05DD"; + NSMutableParagraphStyle *rtl = [NSMutableParagraphStyle new]; + rtl.baseWritingDirection = NSWritingDirectionRightToLeft; + rtl.alignment = NSTextAlignmentRight; + NSMutableParagraphStyle *shown = [rtl mutableCopy]; + shown.baseWritingDirection = NSWritingDirectionLeftToRight; + shown.lineBreakMode = NSLineBreakByTruncatingTail; + + XCTAssertTrue(RCTTestShows(RCTTestString(hebrew, shown), RCTTestString(hebrew, rtl), YES)); + // The alignment is still compared. + NSMutableParagraphStyle *centered = [rtl mutableCopy]; + centered.alignment = NSTextAlignmentCenter; + XCTAssertFalse(RCTTestShows(RCTTestString(hebrew, shown), RCTTestString(hebrew, centered), YES)); + // A multiline input keeps the direction. + XCTAssertFalse(RCTTestShows(RCTTestString(hebrew, shown), RCTTestString(hebrew, rtl), NO)); +} + +- (void)testSamenessOfEmojiAndCombiningMarks +{ + NSString *emoji = + @"a\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466b\U0001F44D\U0001F3FDc\U0001F1EF\U0001F1F5de\u0301f"; + XCTAssertTrue(RCTTestShows(RCTTestString(emoji, RCTTestTypedParagraphStyle()), RCTTestString(emoji, nil), YES)); + NSString *precomposed = [emoji stringByReplacingOccurrencesOfString:@"e\u0301" withString:@"\u00E9"]; + XCTAssertFalse( + RCTTestShows(RCTTestString(precomposed, RCTTestTypedParagraphStyle()), RCTTestString(emoji, nil), YES)); +} + // A std::string may legitimately contain an embedded NUL. The C-string APIs // these converters used to call stop at it, silently truncating user text. diff --git a/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm b/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm new file mode 100644 index 000000000000..4530412fc2d9 --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm @@ -0,0 +1,384 @@ +/* + * 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 +#import +#import +#import +#import +#import +#import +#import + +#if TARGET_OS_IOS + +using namespace facebook::react; + +// Types `text` at the selection one key (composed character sequence) at a time, as UIKit does: asks the delegate +// (-textInputShouldChangeText:inRange:, which sends onKeyPress and applies maxLength), puts the text it allows in +// place of the selection with the typing attributes, moves the caret after it and reports the change. +static void +RCTTestTypeKeys(UIView *input, NSString *text, void (^setText)(NSAttributedString *)) +{ + [text enumerateSubstringsInRange:NSMakeRange(0, text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *key, NSRange, NSRange, BOOL *) { + UITextRange *selection = input.selectedTextRange; + NSInteger start = [input offsetFromPosition:input.beginningOfDocument + toPosition:selection.start]; + NSInteger end = [input offsetFromPosition:input.beginningOfDocument toPosition:selection.end]; + NSRange range = NSMakeRange(start, end - start); + NSString *allowed = [input.textInputDelegate textInputShouldChangeText:key inRange:range]; + if (allowed == nil) { + return; + } + NSMutableAttributedString *typed = [input.attributedText mutableCopy]; + [typed replaceCharactersInRange:range + withAttributedString:[[NSAttributedString alloc] + initWithString:allowed + attributes:input.typingAttributes]]; + setText(typed); + UITextPosition *caret = [input positionFromPosition:input.beginningOfDocument + offset:start + allowed.length]; + [input setSelectedTextRange:[input textRangeFromPosition:caret toPosition:caret] + notifyDelegate:NO]; + [input.textInputDelegate textInputDidChange]; + }]; +} + +// Like UIKit, delivers the keys queued behind -setAttributedText: inside it: before the new text is assigned (a text +// field), or after (a text view can). +static void RCTTestSetAttributedText( + UIView *input, + NSAttributedString *attributedText, + NSString *keys, + BOOL keysArriveAfterAssignment, + void (^setText)(NSAttributedString *)) +{ + if (keys != nil && !keysArriveAfterAssignment) { + RCTTestTypeKeys(input, keys, setText); + } + setText(attributedText); + if (keys != nil && keysArriveAfterAssignment) { + RCTTestTypeKeys(input, keys, setText); + } +} + +@protocol RCTTestKeysDuringWrite +@property (nonatomic, copy, nullable) NSString *keysDuringNextWrite; +@property (nonatomic, assign) BOOL keysArriveAfterAssignment; +@property (nonatomic, assign) NSUInteger writeCount; +@end + +@interface RCTKeysDuringWriteTextField : RCTUITextField +@end + +@implementation RCTKeysDuringWriteTextField + +@synthesize keysDuringNextWrite = _keysDuringNextWrite; +@synthesize keysArriveAfterAssignment = _keysArriveAfterAssignment; +@synthesize writeCount = _writeCount; + +- (void)setAttributedText:(NSAttributedString *)attributedText +{ + NSString *keys = self.keysDuringNextWrite; + self.keysDuringNextWrite = nil; + self.writeCount++; + RCTTestSetAttributedText(self, attributedText, keys, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { + [super setAttributedText:text]; + }); +} + +@end + +@interface RCTKeysDuringWriteTextView : RCTUITextView +@end + +@implementation RCTKeysDuringWriteTextView + +@synthesize keysDuringNextWrite = _keysDuringNextWrite; +@synthesize keysArriveAfterAssignment = _keysArriveAfterAssignment; +@synthesize writeCount = _writeCount; + +- (void)setAttributedText:(NSAttributedString *)attributedText +{ + NSString *keys = self.keysDuringNextWrite; + self.keysDuringNextWrite = nil; + self.writeCount++; + RCTTestSetAttributedText(self, attributedText, keys, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { + [super setAttributedText:text]; + }); +} + +@end + +// The paragraph style UIKit gives text typed into a text field whose text attributes set none. +static NSParagraphStyle *RCTTestUIKitParagraphStyle(void) +{ + NSMutableParagraphStyle *style = [NSParagraphStyle.defaultParagraphStyle mutableCopy]; + style.lineBreakMode = NSLineBreakByTruncatingTail; + style.baseWritingDirection = NSWritingDirectionLeftToRight; + return style; +} + +static NSAttributedString *RCTTestStyled(NSString *text, UIColor *color) +{ + return [[NSAttributedString alloc] initWithString:text + attributes:@{ + NSFontAttributeName : [UIFont boldSystemFontOfSize:16], + NSForegroundColorAttributeName : color, + }]; +} + +@interface RCTTextInputComponentViewTests : XCTestCase +@end + +@implementation RCTTextInputComponentViewTests { + UIWindow *_window; + RCTTextInputComponentView *_view; + UIView *_input; + State::Shared _state; +} + +- (void)tearDown +{ + [_input resignFirstResponder]; + _window.hidden = YES; + _window = nil; + _view = nil; + _input = nil; + _state = nullptr; + [super tearDown]; +} + +// A focused TextInput whose backed text input delivers keys during writes, with an empty state from JS. +- (void)makeInputMultiline:(BOOL)multiline maxLength:(int)maxLength +{ + _window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; + _view = [[RCTTextInputComponentView alloc] initWithFrame:CGRectMake(0, 0, 300, 80)]; + auto props = std::make_shared(); + props->multiline = multiline; + if (maxLength > 0) { + props->maxLength = maxLength; + } + [_view updateProps:props oldProps:nullptr]; + + UIView *original = [_view valueForKey:@"_backedTextInputView"]; + _input = multiline ? [[RCTKeysDuringWriteTextView alloc] initWithFrame:original.frame] + : [[RCTKeysDuringWriteTextField alloc] initWithFrame:original.frame]; + RCTCopyBackedTextInput(original, _input); + _input.textInputDelegate = (id)_view; + [original removeFromSuperview]; + [_view setValue:_input forKey:@"_backedTextInputView"]; + [_view addSubview:_input]; + + [_window addSubview:_view]; + [_window makeKeyAndVisible]; + [self sendStateFromJS:[NSAttributedString new]]; + [_input becomeFirstResponder]; + _input.writeCount = 0; +} + +- (void)typeKeys:(NSString *)keys +{ + UIView *input = _input; + NSUInteger writes = input.writeCount; + RCTTestTypeKeys(input, keys, ^(NSAttributedString *text) { + input.attributedText = text; + }); + input.writeCount = writes; +} + +// Sets the input's text without telling anyone, as UIKit adjusts the attributes of the text it shows. +- (void)setTextQuietly:(NSAttributedString *)text +{ + NSUInteger writes = _input.writeCount; + _input.attributedText = text; + _input.writeCount = writes; +} + +// Commits a state as Fabric does: the text JS holds, with the view's current event count. +- (void)sendStateFromJS:(NSAttributedString *)text +{ + NSNumber *eventCount = [_view valueForKey:@"_mostRecentEventCount"]; + auto data = TextInputState{}; + data.attributedStringBox = RCTAttributedStringBoxFromNSAttributedString(text); + data.mostRecentEventCount = eventCount.longLongValue; + State::Shared state = std::make_shared( + std::make_shared(std::move(data)), ShadowNodeFamily::Weak{}); + [_view updateState:state oldState:_state]; + _state = state; +} + +// Keys queued while a state is committed arrive inside the write if there is one, or after it. +- (void)sendStateFromJS:(NSAttributedString *)text whileTyping:(NSString *)keys +{ + _input.keysDuringNextWrite = keys; + [self sendStateFromJS:text]; + if (_input.keysDuringNextWrite != nil) { + _input.keysDuringNextWrite = nil; + [self typeKeys:keys]; + } +} + +- (NSAttributedString *)textWithDefaultAttributes:(NSString *)text +{ + NSMutableDictionary *attributes = [_input.defaultTextAttributes mutableCopy]; + [attributes removeObjectForKey:RCTAttributedStringEventEmitterKey]; + return [[NSAttributedString alloc] initWithString:text attributes:attributes]; +} + +- (NSAttributedString *)textWithDefaultAttributes:(NSString *)text paragraphStyle:(NSParagraphStyle *)paragraphStyle +{ + NSMutableAttributedString *styled = [[self textWithDefaultAttributes:text] mutableCopy]; + [styled addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:NSMakeRange(0, styled.length)]; + return styled; +} + +#pragma mark - States that change nothing the field shows + +- (void)testControlledValueEqualToTheTypedTextIsNotWrittenWhileKeysArrive +{ + // A controlled `value` equal to what was typed: the state is built from props, without the paragraph style and the + // event emitter UIKit gave the typed text. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"p"]; + NSMutableAttributedString *shown = [_input.attributedText mutableCopy]; + [shown addAttribute:NSParagraphStyleAttributeName value:RCTTestUIKitParagraphStyle() range:NSMakeRange(0, 1)]; + [self setTextQuietly:shown]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"p"] whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + XCTAssertEqual(_input.writeCount, 0u); +} + +- (void)testFirstCharacterWithTheInputsDefaultAttributesIsNotWrittenWhileKeysArrive +{ + // UIKit gives the first character typed into an empty field the field's default attributes (here a background + // color) after it reached JS; the state, built from props, does not have them. + [self makeInputMultiline:NO maxLength:0]; + NSMutableDictionary *defaults = [_input.defaultTextAttributes mutableCopy]; + defaults[NSFontAttributeName] = [UIFont systemFontOfSize:20]; + NSAttributedString *state = [[NSAttributedString alloc] initWithString:@"/" attributes:defaults]; + defaults[NSBackgroundColorAttributeName] = UIColor.clearColor; + _input.defaultTextAttributes = defaults; + [self typeKeys:@"/"]; + NSMutableDictionary *shown = [defaults mutableCopy]; + shown[NSParagraphStyleAttributeName] = RCTTestUIKitParagraphStyle(); + [self setTextQuietly:[[NSAttributedString alloc] initWithString:@"/" attributes:shown]]; + + [self sendStateFromJS:state whileTyping:@"pr"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"/pr"); + XCTAssertEqual(_input.writeCount, 0u); +} + +- (void)testSingleLineStateWithALineHeightIsNotWrittenWhileKeysArrive +{ + // `lineHeight` on a single-line input: a text field shows the text without the line height, with its own writing + // direction and line break mode. + [self makeInputMultiline:NO maxLength:0]; + NSMutableParagraphStyle *stateStyle = [NSMutableParagraphStyle new]; + stateStyle.minimumLineHeight = 26; + stateStyle.maximumLineHeight = 26; + NSMutableParagraphStyle *shownStyle = [RCTTestUIKitParagraphStyle() mutableCopy]; + [self typeKeys:@"p"]; + [self setTextQuietly:[self textWithDefaultAttributes:@"p" paragraphStyle:shownStyle]]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"p" paragraphStyle:stateStyle] whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + XCTAssertEqual(_input.writeCount, 0u); +} + +- (void)testStateWithoutAParagraphStyleIsNotWrittenOverUIKitsInitialTypingParagraphStyle +{ + // After a write (a nested restyle), UIKit can show typed text with the paragraph style of its initial typing + // attributes (another line break strategy than the default one). A state without a paragraph style is the same text. + [self makeInputMultiline:NO maxLength:0]; + NSParagraphStyle *typingStyle = + [[_view valueForKey:@"_originalTypingAttributes"] objectForKey:NSParagraphStyleAttributeName]; + XCTAssertNotNil(typingStyle); + [self typeKeys:@"p"]; + NSMutableAttributedString *shown = [RCTTestStyled(@"p", UIColor.blueColor) mutableCopy]; + [shown addAttribute:NSParagraphStyleAttributeName value:typingStyle range:NSMakeRange(0, 1)]; + [self setTextQuietly:shown]; + + [self sendStateFromJS:RCTTestStyled(@"p", UIColor.blueColor) whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + XCTAssertEqual(_input.writeCount, 0u); +} + +#pragma mark - States that change what the field shows + +- (void)testMultilineStateWithAnotherLineHeightIsWritten +{ + [self makeInputMultiline:YES maxLength:0]; + NSMutableParagraphStyle *style = [NSMutableParagraphStyle new]; + style.minimumLineHeight = 26; + style.maximumLineHeight = 26; + [self setTextQuietly:[self textWithDefaultAttributes:@"abc" paragraphStyle:style]]; + NSMutableParagraphStyle *taller = [style mutableCopy]; + taller.minimumLineHeight = 30; + taller.maximumLineHeight = 30; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"abc" paragraphStyle:taller]]; + + NSParagraphStyle *shown = [_input.attributedText attribute:NSParagraphStyleAttributeName + atIndex:0 + effectiveRange:NULL]; + XCTAssertEqual(shown.minimumLineHeight, 30); +} + +- (void)testMultilineStateWithAnotherLineBreakModeIsWritten +{ + // `lineBreakModeIOS` changed by JS: a text view's -setDefaultTextAttributes: only sets the typing attributes, so the + // text it shows gets the new mode from the state. + [self makeInputMultiline:YES maxLength:0]; + NSMutableParagraphStyle *wordWrapping = [NSMutableParagraphStyle new]; + wordWrapping.lineBreakMode = NSLineBreakByWordWrapping; + NSMutableParagraphStyle *charWrapping = [NSMutableParagraphStyle new]; + charWrapping.lineBreakMode = NSLineBreakByCharWrapping; + [self setTextQuietly:[self textWithDefaultAttributes:@"abc def" paragraphStyle:wordWrapping]]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"abc def" paragraphStyle:charWrapping]]; + + NSParagraphStyle *shown = [_input.attributedText attribute:NSParagraphStyleAttributeName + atIndex:0 + effectiveRange:NULL]; + XCTAssertEqual(shown.lineBreakMode, NSLineBreakByCharWrapping); +} + +- (void)testStateThatRestylesTheTextIsWritten +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + NSMutableAttributedString *restyled = [[self textWithDefaultAttributes:@"abc"] mutableCopy]; + [restyled addAttribute:NSForegroundColorAttributeName value:UIColor.redColor range:NSMakeRange(0, 3)]; + + [self sendStateFromJS:restyled]; + + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL], UIColor.redColor); +} + +- (void)testStateWithOtherTextIsWritten +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); +} + +@end + +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h index 5af991e0bf1c..14ad3f4cd7a3 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.h @@ -65,6 +65,18 @@ BOOL RCTIsAttributedStringEffectivelySame( NSDictionary *insensitiveAttributes, const facebook::react::TextAttributes &baseTextAttributes); +/* + * The same, where `singleLine` says the control is a single-line text field: it lays its line out with its own line + * break mode and writing direction and without a line height (UIKit sets them in the text it shows), so those are not + * compared. + */ +BOOL RCTIsAttributedStringEffectivelySame( + NSAttributedString *text1, + NSAttributedString *text2, + NSDictionary *insensitiveAttributes, + const facebook::react::TextAttributes &baseTextAttributes, + BOOL singleLine); + static inline NSData *RCTWrapEventEmitter(const std::shared_ptr &eventEmitter) { auto eventEmitterPtr = new std::weak_ptr(eventEmitter); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 95ff5a0d180e..7c73d99b3fbc 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -524,7 +524,8 @@ AttributedStringBox RCTAttributedStringBoxFromNSAttributedString(NSAttributedStr static BOOL RCTIsParagraphStyleEffectivelySame( NSParagraphStyle *style1, NSParagraphStyle *style2, - const TextAttributes &baseTextAttributes) + const TextAttributes &baseTextAttributes, + BOOL singleLine) { if (style1 == nil || style2 == nil) { return style1 == nil && style2 == nil; @@ -567,6 +568,17 @@ static BOOL RCTIsParagraphStyleEffectivelySame( } } + // A single-line text field lays its line out with its own line break mode and writing direction and without a line + // height (UIKit sets them in the text it shows): those are not compared. + if (singleLine) { + NSMutableParagraphStyle *mutableStyle2 = [style2 mutableCopy]; + style2 = mutableStyle2; + mutableStyle2.lineBreakMode = style1.lineBreakMode; + mutableStyle2.baseWritingDirection = style1.baseWritingDirection; + mutableStyle2.minimumLineHeight = style1.minimumLineHeight; + mutableStyle2.maximumLineHeight = style1.maximumLineHeight; + } + return [style1 isEqual:style2]; } @@ -575,14 +587,15 @@ static BOOL RCTIsAttributeEffectivelySame( NSDictionary *attributes1, NSDictionary *attributes2, NSDictionary *insensitiveAttributes, - const TextAttributes &baseTextAttributes) + const TextAttributes &baseTextAttributes, + BOOL singleLine) { id attribute1 = attributes1[attributeKey] ?: insensitiveAttributes[attributeKey]; id attribute2 = attributes2[attributeKey] ?: insensitiveAttributes[attributeKey]; // Normalize attributes which can inexact but still effectively the same if ([attributeKey isEqualToString:NSParagraphStyleAttributeName]) { - return RCTIsParagraphStyleEffectivelySame(attribute1, attribute2, baseTextAttributes); + return RCTIsParagraphStyleEffectivelySame(attribute1, attribute2, baseTextAttributes, singleLine); } // Otherwise rely on built-in comparison @@ -593,7 +606,8 @@ BOOL RCTIsAttributedStringEffectivelySame( NSAttributedString *text1, NSAttributedString *text2, NSDictionary *insensitiveAttributes, - const TextAttributes &baseTextAttributes) + const TextAttributes &baseTextAttributes, + BOOL singleLine) { if (![text1.string isEqualToString:text2.string]) { return NO; @@ -630,7 +644,8 @@ BOOL RCTIsAttributedStringEffectivelySame( text1Attributes, text2Attributes, insensitiveAttributes, - baseTextAttributes)) { + baseTextAttributes, + singleLine)) { areAttributesSame = NO; *text1Stop = YES; *text2Stop = YES; @@ -652,7 +667,8 @@ BOOL RCTIsAttributedStringEffectivelySame( text1Attributes, text2Attributes, insensitiveAttributes, - baseTextAttributes)) { + baseTextAttributes, + singleLine)) { areAttributesSame = NO; *text1Stop = YES; *text2Stop = YES; @@ -664,3 +680,12 @@ BOOL RCTIsAttributedStringEffectivelySame( return areAttributesSame; } + +BOOL RCTIsAttributedStringEffectivelySame( + NSAttributedString *text1, + NSAttributedString *text2, + NSDictionary *insensitiveAttributes, + const TextAttributes &baseTextAttributes) +{ + return RCTIsAttributedStringEffectivelySame(text1, text2, insensitiveAttributes, baseTextAttributes, NO); +} diff --git a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js index a6bcda4ec182..db7ca174386f 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js @@ -343,8 +343,62 @@ const styles = StyleSheet.create({ textAlign: 'right', width: 24, }, + typingInput: { + backgroundColor: '#ffffff', + borderColor: '#cccccc', + borderWidth: 1, + color: '#1c1c1e', + fontSize: 16, + height: 36, + marginBottom: 6, + paddingHorizontal: 6, + }, + typingLineHeight: { + lineHeight: 22, + }, }); +// Type quickly into each empty field: every key must land, including the ones +// typed while the field's text makes a round trip through React state +// (react/react-native#58904). JS's copy of each value is shown below. +function TypingRoundTripExample(): React.Node { + const [uncontrolled, setUncontrolled] = React.useState(''); + const [controlled, setControlled] = React.useState(''); + const [lineHeight, setLineHeight] = React.useState(''); + const common = { + autoCapitalize: 'none' as const, + autoCorrect: false, + style: styles.typingInput, + }; + return ( + + + + + + {[uncontrolled, controlled, lineHeight].join('|')} + + + ); +} + const textInputExamples: Array = [ ...TextInputSharedExamples, { @@ -1016,6 +1070,13 @@ const textInputExamples: Array = [ ); }, }, + { + title: 'Typing round trip', + name: 'typingRoundTrip', + render: function (): React.Node { + return ; + }, + }, { title: 'Accessibility', render: function (): React.Node { From 7ce6cd3bbc73ec51b569add59b61ec0c7d42c454 Mon Sep 17 00:00:00 2001 From: Michael Jerris Date: Sun, 18 Oct 2026 07:23:33 -0400 Subject: [PATCH 2/2] Keep keys typed while TextInput writes text from JS (iOS, Fabric) When RCTTextInputComponentView writes JS's text into a focused field, UIKit delivers the keys queued behind -setAttributedText: inside the write; -textInputDidChange ignores them (_comingFromJS) and the write replaces them. The previous commit skips writes that change nothing. The ones left are real changes and lose keys the same way: a nested restyled on every keystroke, and onChangeText transforms applied through setTextAndSelection. Keep the keys instead: - A key that goes through -textInputShouldChangeText:inRange: during a write is recorded: -textInputDidChange records the field's text right after it. Other changes reported during the write are the write's own (a UITextView reports the text JS wrote), so they are not recorded. - After the write (-_setAttributedStringFromJS: from updateState:, and setTextAndSelection), the edit is put back on top of JS's text (RCTTextInputTextToRestore: where it was made if JS changed only attributes; an insertion at the same distance from the end if JS changed the characters) with -replaceRange:withText:, so it takes the typing attributes and is an undoable edit, and is reported to JS as typing (onChange, then onSelectionChange for the caret). - The restored text is cut to maxLength without splitting a composed character sequence (RCTTextInputTextWithinLength, now shared with -textInputShouldChangeText:inRange:). - UIKit can deliver more queued keys inside the restore's edit, before it applies. The caret is put after the range the edit replaces (the typing attributes still come from the text before that range), so they land after the restored keys, stay in the field and are reported with it; their maxLength check counts the restore. The selection moves past them, so the next key follows them. A key delivered there that edits text before the end of that range (a backspace) is dropped: applied, it would delete text the edit then replaces, and the edit would replace other text. - The selection is mapped through the edit (RCTTextInputOffsetAfterEdit), so setTextAndSelection keeps the selection JS asked for, and a caret at the start of a range the user typed over moves past the replacement. - Nothing is put back while text is marked (IME composition), so a composition is never committed as plain text. - Keys UIKit delivers after the assignment (a text view, or a text field with autocorrection) are already in the field: they are only reported, and the caret, which the write had put back before them, goes after them again. Tests: RCTTextInputComponentViewTests (backed text field and text view that deliver keys or a composition inside -setAttributedText:; keys go through -textInputShouldChangeText:inRange:), RCTTextInputUtilsTest. RNTester: TextInput > "Typing round trip" gains nested , transform, multiline append and maxLength formatter fields. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N7ztWHg5Dnq1KeknJQnfex --- .../TextInput/RCTTextInputComponentView.mm | 208 +++++- .../TextInput/RCTTextInputUtils.h | 36 ++ .../TextInput/RCTTextInputUtils.mm | 105 ++++ .../Text/RCTTextInputComponentViewTests.mm | 595 +++++++++++++++++- .../React/Tests/Text/RCTTextInputUtilsTest.mm | 273 ++++++++ .../TextInput/TextInputExample.ios.js | 80 ++- 6 files changed, 1258 insertions(+), 39 deletions(-) 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 ca06968df602..483adb736b14 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -79,6 +79,30 @@ @implementation RCTTextInputComponentView { */ NSDictionary *_originalTypingAttributes; + /* + * While JS's text is written into the field, UIKit can deliver the keys queued behind `-setAttributedText:` inside + * the write, and the write then replaces them. `_textBeforeUpdateFromJS` is the field's text before the write (nil + * outside one). A key that goes through -textInputShouldChangeText:inRange: during the write sets + * `_keyTypedDuringUpdateFromJS`, and the change UIKit reports next is recorded in `_textTypedDuringUpdateFromJS` + * (and the selection it left in `_selectionTypedDuringUpdateFromJS`). + * Other changes reported during the write are the write's own (a text view reports it). A key that leaves marked + * text (IME composition) sets `_markedTextDuringUpdateFromJS`: composition is not put back as plain text. + * See -_setAttributedStringFromJS: and -_restoreTextTypedDuringUpdateFromJS:writtenText:. + */ + NSString *_textBeforeUpdateFromJS; + NSString *_textTypedDuringUpdateFromJS; + AttributedString::Range _selectionTypedDuringUpdateFromJS; + BOOL _keyTypedDuringUpdateFromJS; + BOOL _markedTextDuringUpdateFromJS; + /* + * While -_restoreTextTypedDuringUpdateFromJS:writtenText: puts keys back, the range its edit replaces and the length + * it adds (both 0 outside it). UIKit can deliver more queued keys inside that edit, before it applies: their + * `maxLength` check counts that length, and a key that edits text before the end of that range is dropped (see + * -textInputShouldChangeText:inRange:). + */ + NSRange _rangeBeingRestored; + NSInteger _lengthBeingRestored; + BOOL _hasInputAccessoryView; CGSize _previousContentSize; } @@ -361,10 +385,127 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & } if (_mostRecentEventCount == _state->getData().mostRecentEventCount) { - _comingFromJS = YES; - [self _setAttributedString:RCTNSAttributedStringFromAttributedStringBox(data.attributedStringBox)]; - _comingFromJS = NO; + [self _setAttributedStringFromJS:RCTNSAttributedStringFromAttributedStringBox(data.attributedStringBox)]; + } +} + +// Writes JS's text into the field and puts back the keys UIKit delivered during the write. +- (void)_setAttributedStringFromJS:(NSAttributedString *)attributedString +{ + NSString *textBeforeUpdate = [self _beginUpdateFromJS]; + [self _setAttributedString:attributedString]; + _comingFromJS = NO; + [self _restoreTextTypedDuringUpdateFromJS:textBeforeUpdate writtenText:attributedString.string]; +} + +// Starts a write from JS (`_comingFromJS`): returns the field's text before it. +- (NSString *)_beginUpdateFromJS +{ + _comingFromJS = YES; + _textBeforeUpdateFromJS = [_backedTextInputView.attributedText.string copy]; + _textTypedDuringUpdateFromJS = nil; + _selectionTypedDuringUpdateFromJS = {}; + _keyTypedDuringUpdateFromJS = NO; + _markedTextDuringUpdateFromJS = NO; + return _textBeforeUpdateFromJS; +} + +- (void)_setSelectedRange:(AttributedString::Range)range +{ + UITextPosition *start = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:range.location]; + UITextPosition *end = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:range.location + range.length]; + if (start != nil && end != nil) { + [_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:start toPosition:end] + notifyDelegate:NO]; + } +} + +- (void)_endUpdateFromJS +{ + _textBeforeUpdateFromJS = nil; + _textTypedDuringUpdateFromJS = nil; + _selectionTypedDuringUpdateFromJS = {}; + _keyTypedDuringUpdateFromJS = NO; + _markedTextDuringUpdateFromJS = NO; +} + +// Applies the edit the user made during the write from JS (`textBeforeUpdate` -> the text the keys left, see +// RCTTextInputTextToRestore) to the text JS wrote, within `maxLength`, and reports it as typing (-textInputDidChange, +// then -textInputDidChangeSelection), so JS receives those keys and can still change or reject them. The selection is +// mapped through the edit: JS's selection (setTextAndSelection) or the caret the write restored. Nothing is put back +// while the user composes text (IME marked text): composition is not committed as plain text. +- (void)_restoreTextTypedDuringUpdateFromJS:(NSString *)textBeforeUpdate writtenText:(NSString *)writtenText +{ + NSString *typedText = _textTypedDuringUpdateFromJS; + AttributedString::Range typedSelection = _selectionTypedDuringUpdateFromJS; + BOOL markedText = _markedTextDuringUpdateFromJS || _backedTextInputView.markedTextRange != nil; + [self _endUpdateFromJS]; + if (typedText == nil || markedText) { + return; + } + if (![_backedTextInputView.attributedText.string isEqualToString:writtenText]) { + // The keys arrived after the write had replaced the text (a text view can deliver them there), so the field + // still has them: only report them. The write then put the caret back where it was before the keys: put it + // after them again. + if ([_backedTextInputView.attributedText.string isEqualToString:typedText]) { + [self _setSelectedRange:typedSelection]; + } + [self textInputDidChange]; + [self textInputDidChangeSelection]; + return; + } + NSRange replaced; + NSString *inserted; + if (!RCTTextInputTextToRestore(textBeforeUpdate, typedText, writtenText, &replaced, &inserted)) { + return; + } + const auto &props = static_cast(*_props); + if (inserted.length > 0 && props.maxLength < std::numeric_limits::max()) { + // UIKit checked these keys against the text before the write. + inserted = RCTTextInputTextWithinLength( + inserted, (NSInteger)props.maxLength - (NSInteger)writtenText.length + (NSInteger)replaced.length); + if (inserted == nil) { + return; + } + } + + AttributedString::Range selection = [self _selectionRange]; + UITextPosition *replacedStart = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:replaced.location]; + UITextPosition *replacedEnd = [_backedTextInputView positionFromPosition:replacedStart offset:replaced.length]; + UITextRange *replacedRange = [_backedTextInputView textRangeFromPosition:replacedStart toPosition:replacedEnd]; + if (replacedRange == nil) { + return; } + // Put the keys back as UIKit inserts typed text: as an edit the user can undo, with the attributes of JS's text + // before them (the typing attributes, set after the caret moved). UIKit can deliver more queued keys inside this + // edit, before it applies: the caret is put after `replacedRange`, so they land after it, stay in the field (only + // `replacedRange` is replaced) and are reported with it. + [_backedTextInputView setSelectedTextRange:[_backedTextInputView textRangeFromPosition:replacedEnd + toPosition:replacedEnd] + notifyDelegate:NO]; + NSAttributedString *textBeforeRestore = _backedTextInputView.attributedText; + [self _updateTypingAttributesSampledAt:replaced.location]; + NSInteger lengthAdded = (NSInteger)inserted.length - (NSInteger)replaced.length; + _comingFromJS = YES; + _rangeBeingRestored = replaced; + _lengthBeingRestored = lengthAdded; + [_backedTextInputView replaceRange:replacedRange withText:inserted]; + _rangeBeingRestored = NSMakeRange(0, 0); + _lengthBeingRestored = 0; + // The keys UIKit delivered inside the edit follow the restored ones: the selection moves past them too. + NSInteger delivered = + (NSInteger)_backedTextInputView.attributedText.length - (NSInteger)textBeforeRestore.length - lengthAdded; + NSUInteger insertedLength = inserted.length + (NSUInteger)MAX(delivered, (NSInteger)0); + NSInteger start = RCTTextInputOffsetAfterEdit(selection.location, replaced, insertedLength); + NSInteger end = RCTTextInputOffsetAfterEdit(selection.location + selection.length, replaced, insertedLength); + [self _setSelectedRange:{.location = (int)start, .length = (int)(end - start)}]; + [self _updateTypingAttributes]; + _comingFromJS = NO; + [self textInputDidChange]; + [self textInputDidChangeSelection]; } - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics @@ -466,26 +607,24 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range } } + NSString *allowedText = text; if (props.maxLength < std::numeric_limits::max()) { - NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length; - - if (allowedLength > 0 && text.length > allowedLength) { - // make sure unicode characters that are longer than 16 bits (such as emojis) are not cut off - NSRange cutOffCharacterRange = [text rangeOfComposedCharacterSequenceAtIndex:allowedLength - 1]; - if (cutOffCharacterRange.location + cutOffCharacterRange.length > allowedLength) { - // the character at the length limit takes more than 16bits, truncation should end at the character before - allowedLength = cutOffCharacterRange.location; - } - } - - if (allowedLength <= 0) { - return nil; - } - - return allowedLength > text.length ? text : [text substringToIndex:allowedLength]; + allowedText = RCTTextInputTextWithinLength( + text, + (NSInteger)props.maxLength - (NSInteger)_backedTextInputView.attributedText.string.length - + _lengthBeingRestored + (NSInteger)range.length); } - return text; + if (range.location < NSMaxRange(_rangeBeingRestored)) { + // UIKit delivers this key inside the edit that puts keys back, before it applies, and it edits text before the + // end of the range that edit replaces (a backspace): the edit would then replace other text. The key is dropped. + return nil; + } + if (_textBeforeUpdateFromJS != nil) { + // UIKit delivers this key during a write from JS: see -textInputDidChange. + _keyTypedDuringUpdateFromJS = YES; + } + return allowedText; } - (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text @@ -496,6 +635,14 @@ - (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSStrin - (void)textInputDidChange { if (_comingFromJS) { + // The change a key made during a write from JS (see `_textBeforeUpdateFromJS`). Other changes reported during the + // write are the write's own: a text view reports the text JS wrote. + if (_keyTypedDuringUpdateFromJS) { + _keyTypedDuringUpdateFromJS = NO; + _textTypedDuringUpdateFromJS = [_backedTextInputView.attributedText.string copy]; + _selectionTypedDuringUpdateFromJS = [self _selectionRange]; + _markedTextDuringUpdateFromJS = _markedTextDuringUpdateFromJS || _backedTextInputView.markedTextRange != nil; + } return; } @@ -595,12 +742,14 @@ - (void)setTextAndSelection:(NSInteger)eventCount if (_mostRecentEventCount != eventCount) { return; } - _comingFromJS = YES; + NSString *textBeforeUpdate = [self _beginUpdateFromJS]; + NSString *writtenText = nil; if (value && ![value isEqualToString:_backedTextInputView.attributedText.string]) { NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:value attributes:_backedTextInputView.defaultTextAttributes]; [self _setAttributedString:attributedString]; [self _updateState]; + writtenText = attributedString.string; } UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument @@ -617,6 +766,13 @@ - (void)setTextAndSelection:(NSInteger)eventCount [_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetEnd, 0)]; } _comingFromJS = NO; + // A controlled `value` that JS changed (e.g. a transform in onChangeText) is written here: put back the keys UIKit + // delivered during the write, keeping JS's selection. + if (writtenText != nil) { + [self _restoreTextTypedDuringUpdateFromJS:textBeforeUpdate writtenText:writtenText]; + } else { + [self _endUpdateFromJS]; + } } #pragma mark - Default input accessory view @@ -826,8 +982,16 @@ - (void)_updateTypingAttributes if (_backedTextInputView.attributedText.length > 0 && _backedTextInputView.selectedTextRange != nil) { NSUInteger offsetStart = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument toPosition:_backedTextInputView.selectedTextRange.start]; + [self _updateTypingAttributesSampledAt:offsetStart]; + } +} - NSUInteger samplePoint = offsetStart == 0 ? 0 : offsetStart - 1; +// The typing attributes for text inserted at `offset`: the attributes of the character before it, or after it at the +// start of the field. Used for the caret (-_updateTypingAttributes) and for keys put back after a write from JS. +- (void)_updateTypingAttributesSampledAt:(NSUInteger)offset +{ + if (_backedTextInputView.attributedText.length > 0) { + NSUInteger samplePoint = offset == 0 ? 0 : offset - 1; _backedTextInputView.typingAttributes = [_backedTextInputView.attributedText attributesAtIndex:samplePoint effectiveRange:NULL]; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h index 90a2816bf2ad..0ad5d29c6ffb 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.h @@ -41,6 +41,42 @@ UITextInputPasswordRules *RCTUITextInputPasswordRulesFromString(const std::strin UITextSmartInsertDeleteType RCTUITextSmartInsertDeleteTypeFromOptionalBool(std::optional smartInsertDelete); +/* + * The range of `oldText` that an edit replaced to give `newText`: what lies between their common prefix and their + * common suffix, widened so it does not split a composed character sequence (an emoji, a flag, a letter with + * combining marks). The inserted text is `newText` over the same location, `newText.length - oldText.length + length` + * long. + */ +NSRange RCTTextInputChangedRange(NSString *oldText, NSString *newText); + +/* + * The user typed while JS's text was being written into a focused text input: UIKit delivers keys queued behind + * `-setAttributedText:` inside it, and the write replaces them. Given the input's text before the write + * (`textBeforeUpdate`), what it held with the keys typed during the write (`typedText`), and the text JS wrote + * (`writtenText`), sets the range of `writtenText` to replace and the text to put there, and returns YES; returns NO + * if there is nothing to put back. If JS changed only attributes, the user's edit applies where it was made. If JS + * changed the characters (e.g. upper-casing), only an insertion is put back, at the same distance from the end. + */ +BOOL RCTTextInputTextToRestore( + NSString *textBeforeUpdate, + NSString *typedText, + NSString *writtenText, + NSRange *range, + NSString *_Nullable *_Nonnull text); + +/* + * `text` cut to `allowedLength` (`maxLength`) without splitting a composed character sequence, or nil if no + * character fits. + */ +NSString *_Nullable RCTTextInputTextWithinLength(NSString *text, NSInteger allowedLength); + +/* + * Where `offset` in a text lies after `range` of it was replaced by `insertedLength` characters: an offset at or after + * the end of the range moves with the text after it, an offset inside the range or at its start moves to the end of + * the insertion (the caret of a user who typed over the range). + */ +NSInteger RCTTextInputOffsetAfterEdit(NSInteger offset, NSRange range, NSUInteger insertedLength); + #if !TARGET_OS_TV UIDataDetectorTypes RCTUITextViewDataDetectorTypesFromStringVector(const std::vector &dataDetectorTypes); #endif diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm index e10e01d20430..f6b0e2576019 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm @@ -273,6 +273,111 @@ UITextSmartInsertDeleteType RCTUITextSmartInsertDeleteTypeFromOptionalBool(std:: : UITextSmartInsertDeleteTypeDefault; } +// The end of the composed character sequence (an emoji, a letter with combining marks) that `index` falls in. +static NSUInteger RCTComposedSequenceBoundaryAtOrAfter(NSString *text, NSUInteger index) +{ + if (index == 0 || index >= text.length) { + return index; + } + NSRange sequence = [text rangeOfComposedCharacterSequenceAtIndex:index]; + return sequence.location < index ? NSMaxRange(sequence) : index; +} + +NSRange RCTTextInputChangedRange(NSString *oldText, NSString *newText) +{ + // Read the characters through inline buffers rather than copying both strings. + CFStringInlineBuffer oldBuffer; + CFStringInlineBuffer newBuffer; + CFStringInitInlineBuffer((__bridge CFStringRef)oldText, &oldBuffer, CFRangeMake(0, oldText.length)); + CFStringInitInlineBuffer((__bridge CFStringRef)newText, &newBuffer, CFRangeMake(0, newText.length)); + NSUInteger oldLength = oldText.length; + NSUInteger newLength = newText.length; + NSUInteger shorter = MIN(oldLength, newLength); + NSUInteger prefix = 0; + while (prefix < shorter && + CFStringGetCharacterFromInlineBuffer(&oldBuffer, prefix) == + CFStringGetCharacterFromInlineBuffer(&newBuffer, prefix)) { + prefix++; + } + NSUInteger suffix = 0; + while (suffix < shorter - prefix && + CFStringGetCharacterFromInlineBuffer(&oldBuffer, oldLength - 1 - suffix) == + CFStringGetCharacterFromInlineBuffer(&newBuffer, newLength - 1 - suffix)) { + suffix++; + } + // Don't split a composed character sequence: the edit starts and ends on sequence boundaries of both texts. + if (prefix < oldLength) { + prefix = MIN(prefix, [oldText rangeOfComposedCharacterSequenceAtIndex:prefix].location); + } + if (prefix < newLength) { + prefix = MIN(prefix, [newText rangeOfComposedCharacterSequenceAtIndex:prefix].location); + } + suffix = MIN(suffix, shorter - prefix); + NSUInteger oldEnd = RCTComposedSequenceBoundaryAtOrAfter(oldText, oldLength - suffix); + NSUInteger newEnd = RCTComposedSequenceBoundaryAtOrAfter(newText, newLength - suffix); + suffix = MIN(oldLength - oldEnd, newLength - newEnd); + return NSMakeRange(prefix, oldLength - prefix - suffix); +} + +BOOL RCTTextInputTextToRestore( + NSString *textBeforeUpdate, + NSString *typedText, + NSString *writtenText, + NSRange *range, + NSString *_Nullable *_Nonnull text) +{ + if ([typedText isEqualToString:textBeforeUpdate]) { + return NO; + } + NSRange replaced = RCTTextInputChangedRange(textBeforeUpdate, typedText); + NSUInteger insertedLength = typedText.length - (textBeforeUpdate.length - replaced.length); + NSString *inserted = [typedText substringWithRange:NSMakeRange(replaced.location, insertedLength)]; + if (![writtenText isEqualToString:textBeforeUpdate]) { + // JS changed the characters: put back an insertion only, at the same distance from the end of the text. + if (replaced.length > 0 || insertedLength == 0) { + return NO; + } + NSUInteger distanceFromEnd = textBeforeUpdate.length - replaced.location; + NSUInteger location = writtenText.length > distanceFromEnd ? writtenText.length - distanceFromEnd : 0; + if (location < writtenText.length) { + location = [writtenText rangeOfComposedCharacterSequenceAtIndex:location].location; + } + replaced = NSMakeRange(location, 0); + } + *range = replaced; + *text = inserted; + return YES; +} + +NSString *_Nullable RCTTextInputTextWithinLength(NSString *text, NSInteger allowedLength) +{ + if (allowedLength > 0 && (NSInteger)text.length > allowedLength) { + // make sure unicode characters that are longer than 16 bits (such as emojis) are not cut off + NSRange cutOffCharacterRange = [text rangeOfComposedCharacterSequenceAtIndex:allowedLength - 1]; + if ((NSInteger)NSMaxRange(cutOffCharacterRange) > allowedLength) { + // the character at the length limit takes more than 16bits, truncation should end at the character before + allowedLength = cutOffCharacterRange.location; + } + } + + if (allowedLength <= 0) { + return nil; + } + + return allowedLength > (NSInteger)text.length ? text : [text substringToIndex:allowedLength]; +} + +NSInteger RCTTextInputOffsetAfterEdit(NSInteger offset, NSRange range, NSUInteger insertedLength) +{ + if (offset >= (NSInteger)NSMaxRange(range)) { + return offset + (NSInteger)insertedLength - (NSInteger)range.length; + } + if (offset >= (NSInteger)range.location) { + return range.location + insertedLength; + } + return offset; +} + #if !TARGET_OS_TV UIDataDetectorTypes RCTUITextViewDataDetectorTypesFromStringVector(const std::vector &dataDetectorTypes) { diff --git a/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm b/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm index 4530412fc2d9..11d2d419600b 100644 --- a/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm +++ b/packages/react-native/React/Tests/Text/RCTTextInputComponentViewTests.mm @@ -19,9 +19,20 @@ using namespace facebook::react; +@interface RCTTextInputComponentView (Tests) +- (void)_setAttributedStringFromJS:(NSAttributedString *)attributedString; +- (void)textInputDidChange; +- (void)textInputDidChangeSelection; +- (void)setTextAndSelection:(NSInteger)eventCount + value:(NSString *__nullable)value + start:(NSInteger)start + end:(NSInteger)end; +@end + // Types `text` at the selection one key (composed character sequence) at a time, as UIKit does: asks the delegate // (-textInputShouldChangeText:inRange:, which sends onKeyPress and applies maxLength), puts the text it allows in -// place of the selection with the typing attributes, moves the caret after it and reports the change. +// place of the selection with the typing attributes, moves the caret after it and reports the change. A "\b" key is +// a backspace: it deletes the selection, or the character before the caret. static void RCTTestTypeKeys(UIView *input, NSString *text, void (^setText)(NSAttributedString *)) { @@ -33,6 +44,15 @@ toPosition:selection.start]; NSInteger end = [input offsetFromPosition:input.beginningOfDocument toPosition:selection.end]; NSRange range = NSMakeRange(start, end - start); + if ([key isEqualToString:@"\b"]) { + if (range.length == 0) { + if (start == 0) { + return; + } + range = [input.attributedText.string rangeOfComposedCharacterSequenceAtIndex:start - 1]; + } + key = @""; + } NSString *allowed = [input.textInputDelegate textInputShouldChangeText:key inRange:range]; if (allowed == nil) { return; @@ -44,33 +64,61 @@ attributes:input.typingAttributes]]; setText(typed); UITextPosition *caret = [input positionFromPosition:input.beginningOfDocument - offset:start + allowed.length]; + offset:range.location + allowed.length]; [input setSelectedTextRange:[input textRangeFromPosition:caret toPosition:caret] notifyDelegate:NO]; [input.textInputDelegate textInputDidChange]; }]; } -// Like UIKit, delivers the keys queued behind -setAttributedText: inside it: before the new text is assigned (a text -// field), or after (a text view can). +// IME composition, as UIKit does for a composition key: asks the delegate, shows `markedText` in place of the +// selection, marked, and reports the change. +static void RCTTestSetMarkedText(UIView *input, NSString *markedText) +{ + UITextRange *selection = input.markedTextRange ?: input.selectedTextRange; + NSInteger start = [input offsetFromPosition:input.beginningOfDocument toPosition:selection.start]; + NSInteger end = [input offsetFromPosition:input.beginningOfDocument toPosition:selection.end]; + if ([input.textInputDelegate textInputShouldChangeText:markedText inRange:NSMakeRange(start, end - start)] == nil) { + return; + } + [input setMarkedText:markedText selectedRange:NSMakeRange(markedText.length, 0)]; + [input.textInputDelegate textInputDidChange]; +} + +// Like UIKit, delivers the keys (or a composition) queued behind -setAttributedText: inside it: before the new text is +// assigned (a text field), or after (a text view can). static void RCTTestSetAttributedText( UIView *input, NSAttributedString *attributedText, NSString *keys, + NSString *markedText, BOOL keysArriveAfterAssignment, void (^setText)(NSAttributedString *)) { - if (keys != nil && !keysArriveAfterAssignment) { - RCTTestTypeKeys(input, keys, setText); + if (!keysArriveAfterAssignment) { + if (keys != nil) { + RCTTestTypeKeys(input, keys, setText); + } + if (markedText != nil) { + RCTTestSetMarkedText(input, markedText); + } } setText(attributedText); - if (keys != nil && keysArriveAfterAssignment) { - RCTTestTypeKeys(input, keys, setText); + if (keysArriveAfterAssignment) { + if (keys != nil) { + RCTTestTypeKeys(input, keys, setText); + } + if (markedText != nil) { + RCTTestSetMarkedText(input, markedText); + } } } @protocol RCTTestKeysDuringWrite @property (nonatomic, copy, nullable) NSString *keysDuringNextWrite; +@property (nonatomic, copy, nullable) NSString *markedTextDuringNextWrite; +// Keys UIKit delivers inside -replaceRange:withText:, before the edit applies. +@property (nonatomic, copy, nullable) NSString *keysDuringNextReplace; @property (nonatomic, assign) BOOL keysArriveAfterAssignment; @property (nonatomic, assign) NSUInteger writeCount; @end @@ -81,19 +129,35 @@ @interface RCTKeysDuringWriteTextField : RCTUITextField @implementation RCTKeysDuringWriteTextField @synthesize keysDuringNextWrite = _keysDuringNextWrite; +@synthesize markedTextDuringNextWrite = _markedTextDuringNextWrite; +@synthesize keysDuringNextReplace = _keysDuringNextReplace; @synthesize keysArriveAfterAssignment = _keysArriveAfterAssignment; @synthesize writeCount = _writeCount; - (void)setAttributedText:(NSAttributedString *)attributedText { NSString *keys = self.keysDuringNextWrite; + NSString *markedText = self.markedTextDuringNextWrite; self.keysDuringNextWrite = nil; + self.markedTextDuringNextWrite = nil; self.writeCount++; - RCTTestSetAttributedText(self, attributedText, keys, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { - [super setAttributedText:text]; - }); + RCTTestSetAttributedText( + self, attributedText, keys, markedText, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { + [super setAttributedText:text]; + }); } +- (void)replaceRange:(UITextRange *)range withText:(NSString *)text +{ + NSString *keys = self.keysDuringNextReplace; + self.keysDuringNextReplace = nil; + if (keys != nil) { + RCTTestTypeKeys(self, keys, ^(NSAttributedString *typed) { + [super setAttributedText:typed]; + }); + } + [super replaceRange:range withText:text]; +} @end @interface RCTKeysDuringWriteTextView : RCTUITextView @@ -102,19 +166,35 @@ @interface RCTKeysDuringWriteTextView : RCTUITextView @implementation RCTKeysDuringWriteTextView @synthesize keysDuringNextWrite = _keysDuringNextWrite; +@synthesize markedTextDuringNextWrite = _markedTextDuringNextWrite; +@synthesize keysDuringNextReplace = _keysDuringNextReplace; @synthesize keysArriveAfterAssignment = _keysArriveAfterAssignment; @synthesize writeCount = _writeCount; - (void)setAttributedText:(NSAttributedString *)attributedText { NSString *keys = self.keysDuringNextWrite; + NSString *markedText = self.markedTextDuringNextWrite; self.keysDuringNextWrite = nil; + self.markedTextDuringNextWrite = nil; self.writeCount++; - RCTTestSetAttributedText(self, attributedText, keys, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { - [super setAttributedText:text]; - }); + RCTTestSetAttributedText( + self, attributedText, keys, markedText, self.keysArriveAfterAssignment, ^(NSAttributedString *text) { + [super setAttributedText:text]; + }); } +- (void)replaceRange:(UITextRange *)range withText:(NSString *)text +{ + NSString *keys = self.keysDuringNextReplace; + self.keysDuringNextReplace = nil; + if (keys != nil) { + RCTTestTypeKeys(self, keys, ^(NSAttributedString *typed) { + [super setAttributedText:typed]; + }); + } + [super replaceRange:range withText:text]; +} @end // The paragraph style UIKit gives text typed into a text field whose text attributes set none. @@ -135,6 +215,32 @@ - (void)setAttributedText:(NSAttributedString *)attributedText }]; } +// Records what the view reports to JS outside writes from JS: "change" (onChange) and "selection" +// (onSelectionChange). +@interface RCTTestTextInputComponentView : RCTTextInputComponentView +@property (nonatomic, strong) NSMutableArray *reports; +@end + +@implementation RCTTestTextInputComponentView + +- (void)textInputDidChange +{ + if (![[self valueForKey:@"_comingFromJS"] boolValue]) { + [self.reports addObject:@"change"]; + } + [super textInputDidChange]; +} + +- (void)textInputDidChangeSelection +{ + if (![[self valueForKey:@"_comingFromJS"] boolValue]) { + [self.reports addObject:@"selection"]; + } + [super textInputDidChangeSelection]; +} + +@end + @interface RCTTextInputComponentViewTests : XCTestCase @end @@ -160,7 +266,7 @@ - (void)tearDown - (void)makeInputMultiline:(BOOL)multiline maxLength:(int)maxLength { _window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 480)]; - _view = [[RCTTextInputComponentView alloc] initWithFrame:CGRectMake(0, 0, 300, 80)]; + _view = [[RCTTestTextInputComponentView alloc] initWithFrame:CGRectMake(0, 0, 300, 80)]; auto props = std::make_shared(); props->multiline = multiline; if (maxLength > 0) { @@ -379,6 +485,465 @@ - (void)testStateWithOtherTextIsWritten XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); } +#pragma mark - Keys typed while JS's text is written (they are put back) + +- (void)setTextAndSelectionFromJS:(NSString *)value start:(NSInteger)start end:(NSInteger)end +{ + NSNumber *eventCount = [_view valueForKey:@"_mostRecentEventCount"]; + [_view setTextAndSelection:eventCount.integerValue value:value start:start end:end]; +} + +- (NSMutableArray *)reports +{ + RCTTestTextInputComponentView *view = (RCTTestTextInputComponentView *)_view; + view.reports = view.reports ?: [NSMutableArray new]; + return view.reports; +} + +- (NSInteger)caret +{ + return [_input offsetFromPosition:_input.beginningOfDocument toPosition:_input.selectedTextRange.start]; +} + +- (void)selectFrom:(NSInteger)start length:(NSInteger)length +{ + UITextPosition *from = [_input positionFromPosition:_input.beginningOfDocument offset:start]; + UITextPosition *to = [_input positionFromPosition:_input.beginningOfDocument offset:start + length]; + [_input setSelectedTextRange:[_input textRangeFromPosition:from toPosition:to] notifyDelegate:NO]; +} + +- (void)moveCaretTo:(NSInteger)offset +{ + UITextPosition *position = [_input positionFromPosition:_input.beginningOfDocument offset:offset]; + [_input setSelectedTextRange:[_input textRangeFromPosition:position toPosition:position] notifyDelegate:NO]; +} + +- (void)testKeysTypedWhileJSRestylesTheTextAreKept +{ + // A nested styles the first character: JS writes "p" with the child's attributes, and "ri" arrive meanwhile. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"p"]; + [self.reports removeAllObjects]; + + [self sendStateFromJS:RCTTestStyled(@"p", UIColor.blueColor) whileTyping:@"ri"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + // JS hears about the keys (onChange) and then the caret they moved (onSelectionChange). + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + // JS's style was applied, and the restored keys take it too. + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL], + UIColor.blueColor); + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:2 effectiveRange:NULL], + UIColor.blueColor); + XCTAssertEqual(self.caret, 3); +} + +- (void)testKeysTypedWhileJSTransformsTheTextAreKept +{ + // onChangeText upper-cases: JS writes "ABC" while "d" arrives. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCd"); +} + +- (void)testKeysTypedInAMultilineInputWhileJSTransformsTheTextAreKept +{ + [self makeInputMultiline:YES maxLength:0]; + [self typeKeys:@"abc"]; + [self.reports removeAllObjects]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCd"); + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + XCTAssertEqual(self.caret, 4); +} + +- (void)testKeysThatArriveAfterTheWriteAreNotDuplicated +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"#a Hello"]; + [self moveCaretTo:2]; + _input.keysArriveAfterAssignment = YES; + [self.reports removeAllObjects]; + + [self sendStateFromJS:RCTTestStyled(@"#a Hello", UIColor.blueColor) whileTyping:@"b"]; + + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + NSString *text = _input.attributedText.string; + XCTAssertEqual([text componentsSeparatedByString:@"b"].count, 2u, @"%@", text); + XCTAssertEqual(text.length, 9u, @"%@", text); + // The caret is after the key, where the next key goes (the write had put it back before the key). + XCTAssertEqual(self.caret, (NSInteger)NSMaxRange([text rangeOfString:@"b"])); +} + +- (void)assertKeyEqualToTheTextJSAppendsIsKeptMultiline:(BOOL)multiline +{ + // JS appends "d" while the user types "d": the field holds what the user typed during the write, which is also + // the text JS wrote. Both "d"s are kept. + [self makeInputMultiline:multiline maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"abcd"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcdd", @"multiline=%d", multiline); +} + +- (void)testAKeyEqualToTheTextJSAppendsIsKept +{ + [self assertKeyEqualToTheTextJSAppendsIsKeptMultiline:NO]; +} + +- (void)testAKeyEqualToTheTextJSAppendsIsKeptInAMultilineInput +{ + [self assertKeyEqualToTheTextJSAppendsIsKeptMultiline:YES]; +} + +- (void)testAWriteWithNoKeysTypedLeavesJSText +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); +} + +- (void)testKeysTypedWhileAControlledValueIsSetAreKept +{ + // onChangeText strips "/": TextInput sets the controlled value "" with setTextAndSelection while "pr" arrive. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"/"]; + _input.keysDuringNextWrite = @"pr"; + + [self setTextAndSelectionFromJS:@"" start:0 end:0]; + + XCTAssertEqualObjects(_input.attributedText.string, @"pr"); + XCTAssertEqual(self.caret, 2); +} + +- (void)testAControlledValueSetWithNoKeysTypedLeavesJSText +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + + [self setTextAndSelectionFromJS:@"ABC" start:3 end:3]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); +} + +- (void)testKeysRestoredAfterAControlledValueKeepJSSelection +{ + // JS sets "ABC" and puts the caret at 1; "d" was typed at the end meanwhile. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextWrite = @"d"; + + [self setTextAndSelectionFromJS:@"ABC" start:1 end:1]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCd"); + XCTAssertEqual(self.caret, 1); +} + +- (void)testRestoredKeysAreAnEditTheUserCanUndo +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"p"]; + [self sendStateFromJS:RCTTestStyled(@"p", UIColor.blueColor) whileTyping:@"ri"]; + XCTAssertEqualObjects(_input.attributedText.string, @"pri"); + + XCTAssertTrue(_input.undoManager.canUndo); + [_input.undoManager undo]; + + XCTAssertEqualObjects(_input.attributedText.string, @"p"); +} + +#pragma mark - Text JS inserts with no key typed (a text view reports the write itself) + +- (void)assertJSInsertionIsNotDuplicatedMultiline:(BOOL)multiline focused:(BOOL)focused viaState:(BOOL)viaState +{ + [self makeInputMultiline:multiline maxLength:0]; + [self typeKeys:@"abc"]; + if (!focused) { + [_input resignFirstResponder]; + } + + if (viaState) { + [self sendStateFromJS:[self textWithDefaultAttributes:@"abc def"]]; + } else { + [self setTextAndSelectionFromJS:@"abc def" start:7 end:7]; + } + + XCTAssertEqualObjects( + _input.attributedText.string, @"abc def", @"multiline=%d focused=%d viaState=%d", multiline, focused, viaState); +} + +- (void)testJSInsertionInAFocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:YES viaState:NO]; +} + +- (void)testJSInsertionInAnUnfocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:NO viaState:NO]; +} + +- (void)testStateInsertionInAFocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:YES viaState:YES]; +} + +- (void)testStateInsertionInAnUnfocusedMultilineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:YES focused:NO viaState:YES]; +} + +- (void)testJSInsertionInASingleLineInputIsNotDuplicated +{ + [self assertJSInsertionIsNotDuplicatedMultiline:NO focused:YES viaState:NO]; +} + +- (void)testSetAttributedStringFromJSInAMultilineInputIsNotDuplicated +{ + [self makeInputMultiline:YES maxLength:0]; + [self typeKeys:@"abc"]; + + [_view _setAttributedStringFromJS:[self textWithDefaultAttributes:@"abc def"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abc def"); +} + +#pragma mark - maxLength + +- (void)testKeysRestoredAfterAFormatterWriteRespectMaxLength +{ + // maxLength 5; JS formats "ab" to "ab-cd". "xy" arrive during the write; UIKit checked them against the old length + // (2) and let them through. Put back, they would exceed maxLength. + [self makeInputMultiline:NO maxLength:5]; + [self typeKeys:@"ab"]; + _input.keysDuringNextWrite = @"xy"; + + [self setTextAndSelectionFromJS:@"ab-cd" start:5 end:5]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ab-cd"); +} + +- (void)testKeysRestoredUpToMaxLength +{ + [self makeInputMultiline:NO maxLength:6]; + [self typeKeys:@"ab"]; + _input.keysDuringNextWrite = @"xy"; + + [self setTextAndSelectionFromJS:@"ab-cd" start:5 end:5]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ab-cdx"); +} + +- (void)testKeysRestoredUpToMaxLengthDoNotSplitAnEmoji +{ + // 👍🏽 is 4 UTF-16 units; 2 are left. + [self makeInputMultiline:NO maxLength:7]; + [self typeKeys:@"ab"]; + _input.keysDuringNextWrite = @"\U0001F44D\U0001F3FD"; + + [self setTextAndSelectionFromJS:@"ab-cd" start:5 end:5]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ab-cd"); +} + +- (void)testKeysDeliveredInsideTheRestoreRespectMaxLength +{ + // maxLength 5; JS formats "123" to "12-3"; "4" arrives during the write and is put back ("12-34"). "5" arrives + // inside that edit, before it applies: it must not exceed maxLength. + [self makeInputMultiline:NO maxLength:5]; + [self typeKeys:@"123"]; + _input.keysDuringNextWrite = @"4"; + _input.keysDuringNextReplace = @"5"; + + [self setTextAndSelectionFromJS:@"12-3" start:4 end:4]; + + XCTAssertEqualObjects(_input.attributedText.string, @"12-34"); +} + +- (void)testTypingRespectsMaxLength +{ + [self makeInputMultiline:NO maxLength:3]; + + [self typeKeys:@"abcd"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abc"); +} + +#pragma mark - Keys UIKit delivers inside the edit that puts keys back + +- (void)testKeysDeliveredInsideTheRestoreAreKeptAndReportedOnce +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextReplace = @"e"; + [self.reports removeAllObjects]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCde"); + XCTAssertEqualObjects(self.reports, (@[ @"change", @"selection" ])); + XCTAssertEqual(self.caret, 5); +} + +- (void)assertTheNextKeyFollowsAKeyDeliveredInsideTheRestoreMultiline:(BOOL)multiline +{ + [self makeInputMultiline:multiline maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextReplace = @"e"; + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"] whileTyping:@"d"]; + + [self typeKeys:@"f"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCdef"); +} + +- (void)testTheNextKeyFollowsAKeyDeliveredInsideTheRestore +{ + [self assertTheNextKeyFollowsAKeyDeliveredInsideTheRestoreMultiline:NO]; +} + +- (void)testTheNextKeyFollowsAKeyDeliveredInsideTheRestoreInAMultilineInput +{ + [self assertTheNextKeyFollowsAKeyDeliveredInsideTheRestoreMultiline:YES]; +} + +- (void)testTheNextKeyFollowsAKeyDeliveredInsideTheRestoreAfterAControlledValue +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextWrite = @"d"; + _input.keysDuringNextReplace = @"e"; + [self setTextAndSelectionFromJS:@"ABC" start:3 end:3]; + + [self typeKeys:@"f"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABCdef"); +} + +- (void)testAKeyDeliveredInsideTheRestoreOfAReplacementIsKept +{ + // "d" is selected and "X" typed over it while JS restyles the text; "Y" arrives inside the edit that puts "X" back. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcd"]; + [self selectFrom:3 length:1]; + _input.keysDuringNextReplace = @"Y"; + NSMutableAttributedString *restyled = [RCTTestStyled(@"abc", UIColor.blueColor) mutableCopy]; + [restyled appendAttributedString:RCTTestStyled(@"d", UIColor.redColor)]; + + [self sendStateFromJS:restyled whileTyping:@"X"]; + [self typeKeys:@"Z"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcXYZ"); + // Typed over a selection, "X" takes the style of the text before it. + XCTAssertEqualObjects( + [_input.attributedText attribute:NSForegroundColorAttributeName atIndex:3 effectiveRange:NULL], + UIColor.blueColor); +} + +- (void)testAKeyTypedInTheRangeARestoreReplacedIsKept +{ + // "abc" is selected and "X" typed over it while JS restyles the text; "Y" arrives inside the edit that puts "X" back. + // Keys inside the range that edit replaces are dropped only while it applies: "Z", typed there next, is kept. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcd"]; + [self selectFrom:0 length:3]; + _input.keysDuringNextReplace = @"Y"; + NSMutableAttributedString *restyled = [RCTTestStyled(@"ab", UIColor.blueColor) mutableCopy]; + [restyled appendAttributedString:RCTTestStyled(@"cd", UIColor.redColor)]; + + [self sendStateFromJS:restyled whileTyping:@"X"]; + XCTAssertEqualObjects(_input.attributedText.string, @"XYd"); + XCTAssertEqual(self.caret, 2); + [self typeKeys:@"Z"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"XYZd"); +} + +- (void)testAKeyDeliveredInsideTheRestoreOfABackspaceIsKept +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcd"]; + _input.keysDuringNextReplace = @"e"; + + [self sendStateFromJS:RCTTestStyled(@"abcd", UIColor.blueColor) whileTyping:@"\b"]; + [self typeKeys:@"f"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcef"); +} + +- (void)testABackspaceDeliveredInsideTheRestoreIsDropped +{ + // "d" is typed while JS restyles the text, and a backspace arrives inside the edit that puts "d" back. Applied, it + // would delete "c" (the caret is after the range that edit replaces), and the edit would then insert past the end. + // It is dropped: one backspace is lost; the other text and the caret stay right. + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.keysDuringNextReplace = @"\b"; + + [self sendStateFromJS:RCTTestStyled(@"abc", UIColor.blueColor) whileTyping:@"d"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abcd"); + XCTAssertEqual(self.caret, 4); +} + +- (void)testABackspaceDeliveredInsideTheRestoreOfABackspaceIsDropped +{ + // In the middle of the text: applied, the second backspace would delete "c", and the edit that puts back the first + // one would then delete "d". + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abcde"]; + [self moveCaretTo:3]; + _input.keysDuringNextReplace = @"\b"; + + [self sendStateFromJS:RCTTestStyled(@"abcde", UIColor.blueColor) whileTyping:@"\b"]; + + XCTAssertEqualObjects(_input.attributedText.string, @"abde"); + XCTAssertEqual(self.caret, 2); +} + +#pragma mark - IME composition (marked text) + +- (void)testMarkedTextStartedDuringAWriteIsNotCommittedAsPlainText +{ + [self makeInputMultiline:NO maxLength:0]; + [self typeKeys:@"abc"]; + _input.markedTextDuringNextWrite = @"\u306B"; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + // The write ended the composition; JS's text is left as it is, without the composed text as plain text. + XCTAssertEqualObjects(_input.attributedText.string, @"ABC"); + XCTAssertNil(_input.markedTextRange); +} + +- (void)testMarkedTextThatArrivesAfterAWriteIsLeftComposing +{ + [self makeInputMultiline:YES maxLength:0]; + [self typeKeys:@"abc"]; + _input.markedTextDuringNextWrite = @"\u306B"; + _input.keysArriveAfterAssignment = YES; + + NSNumber *eventCount = [_view valueForKey:@"_mostRecentEventCount"]; + + [self sendStateFromJS:[self textWithDefaultAttributes:@"ABC"]]; + + XCTAssertEqualObjects(_input.attributedText.string, @"ABC\u306B"); + XCTAssertNotNil(_input.markedTextRange); + XCTAssertEqual(_input.writeCount, 1u); + // Nothing is put back or reported while the user composes. + XCTAssertEqualObjects([_view valueForKey:@"_mostRecentEventCount"], eventCount); +} + @end #endif diff --git a/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm b/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm index 0adb9424c4af..0024d2527107 100644 --- a/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm +++ b/packages/react-native/React/Tests/Text/RCTTextInputUtilsTest.mm @@ -13,6 +13,35 @@ @interface RCTTextInputUtilsTest : XCTestCase @end +// Random short text over characters that make composed sequences: emoji, skin tones, flags, combining marks, ZWJ. +static NSString *RCTTestRandomText(uint32_t *seed, NSUInteger maxCharacters) +{ + NSArray *alphabet = @[ + @"a", + @"b", + @"a", + @"b", + @"c", + @"\U0001F44D", + @"\U0001F3FD", + @"\U0001F1EF", + @"\U0001F1F5", + @"\u00E9", + @"\u0301", + @"\u200D", + @"\U0001F468", + @"\u05E9", + @" ", + @"\n", + ]; + NSMutableString *text = [NSMutableString new]; + NSUInteger count = rand_r(seed) % maxCharacters; + for (NSUInteger i = 0; i < count; i++) { + [text appendString:alphabet[rand_r(seed) % alphabet.count]]; + } + return text; +} + @implementation RCTTextInputUtilsTest - (void)testCopyBackedTextInputPreservesTintColor @@ -37,4 +66,248 @@ - (void)testCopyBackedTextInputPreservesReturnKeyType XCTAssertEqual(destination.returnKeyType, UIReturnKeyDone); } +#pragma mark - RCTTextInputChangedRange + +- (void)testChangedRangeOfAnInsertion +{ + // "p" typed, then "ri" delivered during a write: inserted at 1, nothing replaced. + NSRange range = RCTTextInputChangedRange(@"p", @"pri"); + XCTAssertEqual(range.location, 1u); + XCTAssertEqual(range.length, 0u); +} + +- (void)testChangedRangeOfAnInsertionInTheMiddle +{ + NSRange range = RCTTextInputChangedRange(@"Hello World", @"Hello #tag World"); + XCTAssertEqual(range.location, 6u); + XCTAssertEqual(range.length, 0u); +} + +- (void)testChangedRangeOfAReplacement +{ + NSRange range = RCTTextInputChangedRange(@"abcdef", @"abXYef"); + XCTAssertEqual(range.location, 2u); + XCTAssertEqual(range.length, 2u); +} + +- (void)testChangedRangeOfADeletion +{ + NSRange range = RCTTextInputChangedRange(@"abcdef", @"abef"); + XCTAssertEqual(range.location, 2u); + XCTAssertEqual(range.length, 2u); +} + +- (void)testChangedRangeOfARepeatedCharacter +{ + NSRange range = RCTTextInputChangedRange(@"aa", @"aaa"); + XCTAssertEqual(range.length, 0u); + XCTAssertEqual(range.location, 2u); +} + +- (void)testChangedRangeDoesNotSplitAnEmoji +{ + // 👨 and 👩 share their high surrogate. + NSRange range = RCTTextInputChangedRange(@"a\U0001F468b", @"a\U0001F469b"); + XCTAssertEqual(range.location, 1u); + XCTAssertEqual(range.length, 2u); +} + +- (void)testChangedRangeKeepsACombiningMarkWithItsLetter +{ + // A combining acute accent typed after "e": the edit replaces "e" with "e\u0301". + NSRange range = RCTTextInputChangedRange(@"cafe", @"cafe\u0301"); + XCTAssertEqual(range.location, 3u); + XCTAssertEqual(range.length, 1u); +} + +- (void)testChangedRangeOfAFlagAndASkinTone +{ + NSRange flag = RCTTextInputChangedRange(@"ab", @"a\U0001F1EF\U0001F1F5b"); + XCTAssertEqual(flag.location, 1u); + XCTAssertEqual(flag.length, 0u); + // A skin tone added to 👍 changes that emoji as a whole. + NSRange tone = RCTTextInputChangedRange(@"\U0001F44D", @"\U0001F44D\U0001F3FD"); + XCTAssertEqual(tone.location, 0u); + XCTAssertEqual(tone.length, 2u); +} + +#pragma mark - RCTTextInputTextToRestore + +- (void)testRestoresKeysTypedDuringAnAttributeOnlyWrite +{ + // "p" typed; while JS restyles it (a nested ), "ri" arrive and the write replaces them. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"p", @"pri", @"p", &range, &text)); + XCTAssertEqual(range.location, 1u); + XCTAssertEqual(range.length, 0u); + XCTAssertEqualObjects(text, @"ri"); +} + +- (void)testRestoresABackspaceTypedDuringAnAttributeOnlyWrite +{ + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"abc", @"ab", @"abc", &range, &text)); + XCTAssertEqual(range.location, 2u); + XCTAssertEqual(range.length, 1u); + XCTAssertEqualObjects(text, @""); +} + +- (void)testRestoresAnInsertionMadeInTheMiddleDuringAnAttributeOnlyWrite +{ + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"#a Hello", @"#ab Hello", @"#a Hello", &range, &text)); + XCTAssertEqual(range.location, 2u); + XCTAssertEqualObjects(text, @"b"); +} + +- (void)testRestoresKeysTypedDuringAnUpperCasingWrite +{ + // "abc" was typed; JS writes "ABC"; "d" arrived during the write. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"abc", @"abcd", @"ABC", &range, &text)); + XCTAssertEqual(range.location, 3u); + XCTAssertEqual(range.length, 0u); + XCTAssertEqualObjects(text, @"d"); +} + +- (void)testRestoresAnInsertionInTheMiddleAtTheSameDistanceFromTheEnd +{ + // The caret is before "F": "x" was typed, JS upper-cases it, "y" arrived during the write. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"ABCDExF", @"ABCDExyF", @"ABCDEXF", &range, &text)); + XCTAssertEqual(range.location, 6u); + XCTAssertEqualObjects(text, @"y"); +} + +- (void)testRestoresAnInsertionWhenJSShortensTheText +{ + // JS strips spaces: "a b" becomes "ab", "c" arrived during the write. + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore(@"a b", @"a bc", @"ab", &range, &text)); + XCTAssertEqual(range.location, 2u); + XCTAssertEqualObjects(text, @"c"); +} + +- (void)testDoesNotRestoreAReplacementWhenJSChangedTheCharacters +{ + NSRange range; + NSString *text; + XCTAssertFalse(RCTTextInputTextToRestore(@"abc", @"ab", @"ABC", &range, &text)); + XCTAssertFalse(RCTTextInputTextToRestore(@"abc", @"abc", @"ABC", &range, &text)); +} + +- (void)testRestoresAnEmojiWithoutSplittingJSText +{ + // An emoji typed after a flag; JS upper-cased "a" so the characters changed. + NSString *flag = @"\U0001F1EF\U0001F1F5"; + NSString *family = @"\U0001F468\u200D\U0001F469\u200D\U0001F467"; + NSRange range; + NSString *text; + XCTAssertTrue(RCTTextInputTextToRestore( + [@"a" stringByAppendingString:flag], + [[@"a" stringByAppendingString:flag] stringByAppendingString:family], + [@"A" stringByAppendingString:flag], + &range, + &text)); + XCTAssertEqual(range.location, 1 + flag.length); + XCTAssertEqualObjects(text, family); +} + +- (void)testChangedRangeRebuildsTheNewTextFromRandomEdits +{ + uint32_t seed = 58904; + for (int i = 0; i < 20000; i++) { + NSString *oldText = RCTTestRandomText(&seed, 8); + NSString *newText = RCTTestRandomText(&seed, 8); + if (rand_r(&seed) % 2) { + // Mostly small edits of oldText. + NSMutableString *edited = [oldText mutableCopy]; + [edited insertString:RCTTestRandomText(&seed, 3) atIndex:edited.length ? rand_r(&seed) % (edited.length + 1) : 0]; + newText = edited; + } + NSRange range = RCTTextInputChangedRange(oldText, newText); + XCTAssertLessThanOrEqual(NSMaxRange(range), oldText.length); + NSInteger insertedLength = (NSInteger)newText.length - (NSInteger)oldText.length + (NSInteger)range.length; + XCTAssertGreaterThanOrEqual(insertedLength, 0); + NSString *inserted = [newText substringWithRange:NSMakeRange(range.location, insertedLength)]; + NSString *rebuilt = [oldText stringByReplacingCharactersInRange:range withString:inserted]; + if (![rebuilt isEqualToString:newText]) { + XCTFail(@"old=%@ new=%@ range=%@", oldText, newText, NSStringFromRange(range)); + return; + } + } +} + +- (void)testTextToRestoreAfterAnAttributeOnlyWriteRebuildsTheTypedText +{ + uint32_t seed = 4242; + for (int i = 0; i < 20000; i++) { + NSString *before = RCTTestRandomText(&seed, 8); + NSMutableString *typed = [before mutableCopy]; + NSUInteger at = typed.length ? rand_r(&seed) % (typed.length + 1) : 0; + // UIKit inserts keys on composed character boundaries. + if (at < typed.length) { + at = [typed rangeOfComposedCharacterSequenceAtIndex:at].location; + } + [typed insertString:RCTTestRandomText(&seed, 3) atIndex:at]; + NSRange range; + NSString *text = nil; + if (!RCTTextInputTextToRestore(before, typed, before, &range, &text)) { + XCTAssertEqualObjects(typed, before); + continue; + } + NSString *rebuilt = [before stringByReplacingCharactersInRange:range withString:text]; + if (![rebuilt isEqualToString:typed]) { + XCTFail(@"before=%@ typed=%@ range=%@ text=%@", before, typed, NSStringFromRange(range), text); + return; + } + } +} + +#pragma mark - RCTTextInputTextWithinLength + +- (void)testTextWithinLength +{ + XCTAssertEqualObjects(RCTTextInputTextWithinLength(@"xy", 5), @"xy"); + XCTAssertEqualObjects(RCTTextInputTextWithinLength(@"xy", 2), @"xy"); + XCTAssertEqualObjects(RCTTextInputTextWithinLength(@"xy", 1), @"x"); + XCTAssertNil(RCTTextInputTextWithinLength(@"xy", 0)); + XCTAssertNil(RCTTextInputTextWithinLength(@"xy", -3)); +} + +- (void)testTextWithinLengthDoesNotSplitAComposedCharacterSequence +{ + // 👍🏽 is 4 UTF-16 units. + NSString *thumb = @"\U0001F44D\U0001F3FD"; + XCTAssertNil(RCTTextInputTextWithinLength(thumb, 2)); + XCTAssertEqualObjects(RCTTextInputTextWithinLength([@"a" stringByAppendingString:thumb], 3), @"a"); + XCTAssertEqualObjects( + RCTTextInputTextWithinLength([@"a" stringByAppendingString:thumb], 5), [@"a" stringByAppendingString:thumb]); +} + +#pragma mark - RCTTextInputOffsetAfterEdit + +- (void)testOffsetAfterAnInsertion +{ + // "ri" inserted at 1. + XCTAssertEqual(RCTTextInputOffsetAfterEdit(0, NSMakeRange(1, 0), 2), 0); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(1, NSMakeRange(1, 0), 2), 3); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(4, NSMakeRange(1, 0), 2), 6); +} + +- (void)testOffsetAfterAReplacementAndADeletion +{ + XCTAssertEqual(RCTTextInputOffsetAfterEdit(3, NSMakeRange(2, 2), 1), 3); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(5, NSMakeRange(2, 2), 1), 4); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(2, NSMakeRange(2, 2), 1), 3); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(1, NSMakeRange(2, 2), 1), 1); + XCTAssertEqual(RCTTextInputOffsetAfterEdit(3, NSMakeRange(2, 1), 0), 2); +} + @end diff --git a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js index db7ca174386f..4db93013cde3 100644 --- a/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js +++ b/packages/rn-tester/js/examples/TextInput/TextInputExample.ios.js @@ -356,15 +356,31 @@ const styles = StyleSheet.create({ typingLineHeight: { lineHeight: 22, }, + typingMultiline: { + height: 60, + }, + typingNested: { + color: '#0a63d6', + fontWeight: '600', + }, }); // Type quickly into each empty field: every key must land, including the ones // typed while the field's text makes a round trip through React state -// (react/react-native#58904). JS's copy of each value is shown below. +// (react/react-native#58904). JS's copy of each value is shown below. In the +// multiline field, type "abc" then press "Append": it must read "abc def" +// once. The formatted field turns digits into "12-34" and never exceeds 5 +// characters. function TypingRoundTripExample(): React.Node { const [uncontrolled, setUncontrolled] = React.useState(''); const [controlled, setControlled] = React.useState(''); const [lineHeight, setLineHeight] = React.useState(''); + const [nested, setNested] = React.useState(''); + const [nestedMultiline, setNestedMultiline] = React.useState(''); + const [uppercase, setUppercase] = React.useState(''); + const [stripped, setStripped] = React.useState(''); + const [multiline, setMultiline] = React.useState(''); + const [formatted, setFormatted] = React.useState(''); const common = { autoCapitalize: 'none' as const, autoCorrect: false, @@ -392,8 +408,68 @@ function TypingRoundTripExample(): React.Node { value={lineHeight} onChangeText={setLineHeight} /> + + {nested} + + + {nestedMultiline} + + setUppercase(text.toUpperCase())} + /> + setStripped(text.replace(/\//g, ''))} + /> + +