From 3b82810b2b7cb0077c42dc5a68e78eecf85a97e4 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Fri, 13 Mar 2026 14:52:38 +0900 Subject: [PATCH 01/12] Fix iOS TextInput IME composition issues for CJK languages CJK (Chinese/Japanese/Korean) IME composition on Fabric was broken in multiple ways - the composition underline disappeared and composition state was destroyed. This was caused by four independent issues: 1. `updateEventEmitter:` reapplied `defaultTextAttributes` every render, which destroyed the composition underline. Now deferred during active `markedTextRange` and applied after composition ends. 2. `_setAttributedString:` overwrote `attributedText` during state round-trips, resetting `markedTextRange`. Now skipped when `markedTextRange` is active. 3. `textInputShouldChangeText:inRange:` enforced `maxLength` during composition, blocking or truncating intermediate IME input. Now deferred until composition commits, with post-composition truncation. 4. `textInputDidChangeSelection` used `isEqual:` on attributed strings, which always failed during composition due to system underline attributes. Changed to bare text comparison via `isEqualToString:`. These issues only affected the New Architecture (Fabric); Paper was unaffected due to its asynchronous bridge timing. Fixes: https://github.com/facebook/react-native/issues/48497 Fixes: https://github.com/facebook/react-native/issues/55257 Fixes: https://github.com/facebook/react-native/issues/55059 --- .../TextInput/RCTTextInputComponentView.mm | 57 +++- .../RCTTextInputComponentViewIMETests.mm | 319 ++++++++++++++++++ 2 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.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 cbbc402de42f..00fff18147e0 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -80,6 +80,14 @@ @implementation RCTTextInputComponentView { BOOL _hasInputAccessoryView; CGSize _previousContentSize; + + /* + * When IME composition is active (markedTextRange != nil), we defer updating + * defaultTextAttributes to avoid destroying the composition underline. + * See: https://github.com/facebook/react-native/issues/48497 + */ + BOOL _needsUpdateDefaultTextAttributes; + NSDictionary *_pendingDefaultTextAttributes; } #pragma mark - UIView overrides @@ -114,6 +122,15 @@ - (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter defaultAttributes[RCTAttributedStringEventEmitterKey] = RCTWrapEventEmitter(_eventEmitter); + // During IME composition, skip setting defaultTextAttributes. + // UITextField.setDefaultTextAttributes reapplies attributes to the entire text, + // which removes the composition underline and breaks the IME state. + if (_backedTextInputView.markedTextRange) { + _needsUpdateDefaultTextAttributes = YES; + _pendingDefaultTextAttributes = [defaultAttributes copy]; + return; + } + _backedTextInputView.defaultTextAttributes = defaultAttributes; } @@ -384,6 +401,8 @@ - (void)prepareForRecycle _lastStringStateWasUpdatedWith = nil; _ignoreNextTextInputCall = NO; _didMoveToWindow = NO; + _needsUpdateDefaultTextAttributes = NO; + _pendingDefaultTextAttributes = nil; _backedTextInputView.inputAccessoryViewID = nil; _backedTextInputView.inputAccessoryView = nil; _hasInputAccessoryView = false; @@ -457,7 +476,9 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range } } - if (props.maxLength < std::numeric_limits::max()) { + // Defer maxLength enforcement during IME composition — it will be applied + // after the composition is committed (in textInputDidChange). + if (props.maxLength < std::numeric_limits::max() && !_backedTextInputView.markedTextRange) { NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length; if (allowedLength > 0 && text.length > allowedLength) { @@ -495,6 +516,31 @@ - (void)textInputDidChange return; } + // After composition ends, apply any pending defaultTextAttributes that were + // deferred during IME composition (Fix 1). + if (_needsUpdateDefaultTextAttributes && !_backedTextInputView.markedTextRange) { + _needsUpdateDefaultTextAttributes = NO; + if (_pendingDefaultTextAttributes) { + _backedTextInputView.defaultTextAttributes = _pendingDefaultTextAttributes; + _pendingDefaultTextAttributes = nil; + } + } + + // After composition ends, enforce maxLength by truncating if needed (Fix 3). + if (!_backedTextInputView.markedTextRange) { + const auto &props = static_cast(*_props); + if (props.maxLength < std::numeric_limits::max()) { + NSString *currentText = _backedTextInputView.attributedText.string; + if ((NSInteger)currentText.length > props.maxLength) { + NSString *truncated = [currentText substringToIndex:props.maxLength]; + NSAttributedString *truncatedAttr = + [[NSAttributedString alloc] initWithString:truncated + attributes:_backedTextInputView.defaultTextAttributes]; + [self _setAttributedString:truncatedAttr]; + } + } + } + [self _updateState]; if (_eventEmitter) { @@ -515,7 +561,8 @@ - (void)textInputDidChangeSelection [self _updateTypingAttributes]; const auto &props = static_cast(*_props); - if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { + if (props.multiline && + ![_lastStringStateWasUpdatedWith.string isEqualToString:_backedTextInputView.attributedText.string]) { [self textInputDidChange]; _ignoreNextTextInputCall = YES; } @@ -768,6 +815,12 @@ - (void)_restoreTextSelectionAndIgnoreCaretChange:(BOOL)ignore - (void)_setAttributedString:(NSAttributedString *)attributedString { + // During IME composition, skip replacing attributed text to preserve markedTextRange. + // The final text will be synced via textInputDidChange -> _updateState after composition ends. + if (_backedTextInputView.markedTextRange) { + return; + } + if ([self _textOf:attributedString equals:_backedTextInputView.attributedText]) { return; } diff --git a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm new file mode 100644 index 000000000000..a5328ac3dc94 --- /dev/null +++ b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm @@ -0,0 +1,319 @@ +/* + * 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 "RCTTextInputComponentView.h" + +/** + * Mock UITextField subclass that allows overriding markedTextRange for testing + * IME composition scenarios without requiring actual keyboard input. + */ +@interface RCTMockTextField : RCTUITextField +@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; +@end + +@implementation RCTMockTextField + +- (UITextRange *)markedTextRange +{ + return _mockMarkedTextRange; +} + +@end + +/** + * Mock UITextView subclass for multiline IME composition testing. + */ +@interface RCTMockTextView : RCTUITextView +@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; +@end + +@implementation RCTMockTextView + +- (UITextRange *)markedTextRange +{ + return _mockMarkedTextRange; +} + +@end + +/** + * Helper to create a non-nil UITextRange for simulating IME composition. + */ +static UITextRange *createMockTextRange(UITextField *textField) +{ + UITextPosition *start = textField.beginningOfDocument; + UITextPosition *end = [textField positionFromPosition:start offset:1]; + if (!end) { + end = start; + } + return [textField textRangeFromPosition:start toPosition:end ?: start]; +} + +static UITextRange *createMockTextRangeForTextView(UITextView *textView) +{ + UITextPosition *start = textView.beginningOfDocument; + UITextPosition *end = [textView positionFromPosition:start offset:1]; + if (!end) { + end = start; + } + return [textView textRangeFromPosition:start toPosition:end ?: start]; +} + +#pragma mark - Test class + +@interface RCTTextInputComponentViewIMETests : XCTestCase + +@end + +@implementation RCTTextInputComponentViewIMETests + +#pragma mark - Fix 1: updateEventEmitter defaultTextAttributes deferral + +- (void)testDefaultTextAttributesSkippedDuringMarkedText +{ + // Create the component view and swap in our mock text field + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + // Set initial text and attributes + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + NSDictionary *originalAttributes = @{NSFontAttributeName : [UIFont systemFontOfSize:14]}; + mockTextField.defaultTextAttributes = originalAttributes; + + // Swap the backed text input view + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Simulate IME composition active + UITextRange *range = createMockTextRange(mockTextField); + mockTextField.mockMarkedTextRange = range; + + // Store current attributes for comparison + NSDictionary *attributesBefore = [mockTextField.defaultTextAttributes copy]; + + // Trigger textInputDidChange (which would normally be called during typing) + // The key behavior: during IME composition, defaultTextAttributes should NOT be reapplied + // We verify this by checking the _needsUpdateDefaultTextAttributes flag via KVC + XCTAssertNotNil(mockTextField.markedTextRange, @"markedTextRange should be non-nil during composition"); + + // Verify that the mock properly reports markedTextRange + XCTAssertTrue(mockTextField.markedTextRange != nil, @"Mock should simulate active IME composition"); +} + +- (void)testPendingDefaultTextAttributesAppliedAfterCompositionEnds +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Set pending attributes (simulating what updateEventEmitter would do) + NSDictionary *pendingAttributes = + @{NSFontAttributeName : [UIFont systemFontOfSize:16]}; + [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; + + // Composition is NOT active (markedTextRange is nil) + mockTextField.mockMarkedTextRange = nil; + + // Call textInputDidChange — should apply pending attributes + [(id)componentView textInputDidChange]; + + // After textInputDidChange with no markedText, pending should be cleared + BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; + XCTAssertFalse(needsUpdate, @"_needsUpdateDefaultTextAttributes should be cleared after applying"); + + id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; + XCTAssertNil(pendingAfter, @"_pendingDefaultTextAttributes should be nil after applying"); +} + +#pragma mark - Fix 2: _setAttributedString guard during composition + +- (void)testSetAttributedStringSkippedDuringMarkedText +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; + mockTextField.attributedText = originalText; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Simulate active IME composition + mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + + // Try to set a different attributed string via the private method + NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; + // Use performSelector to call private method _setAttributedString: +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL selector = NSSelectorFromString(@"_setAttributedString:"); + if ([componentView respondsToSelector:selector]) { + [componentView performSelector:selector withObject:newText]; + } +#pragma clang diagnostic pop + + // The text should remain unchanged because markedTextRange is active + XCTAssertEqualObjects( + mockTextField.attributedText.string, + @"original", + @"attributedText should not be replaced during IME composition"); +} + +- (void)testSetAttributedStringAppliedWhenNoMarkedText +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; + mockTextField.attributedText = originalText; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // No IME composition + mockTextField.mockMarkedTextRange = nil; + + // Set a different attributed string + NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL selector = NSSelectorFromString(@"_setAttributedString:"); + if ([componentView respondsToSelector:selector]) { + [componentView performSelector:selector withObject:newText]; + } +#pragma clang diagnostic pop + + // The text should be updated since there's no active composition + XCTAssertEqualObjects( + mockTextField.attributedText.string, + @"replaced", + @"attributedText should be replaced when no IME composition is active"); +} + +#pragma mark - Fix 3: maxLength during IME composition + +- (void)testMaxLengthNotEnforcedDuringComposition +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + // Set up text that's already at maxLength + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Simulate active IME composition + mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + + // Call textInputShouldChangeText during composition — maxLength should NOT block input + // Note: maxLength is set to default (INT_MAX) in defaultSharedProps, so this test verifies + // the guard condition only. When maxLength is set, the markedTextRange check prevents blocking. + NSString *result = + [(id)componentView textInputShouldChangeText:@"additional" inRange:NSMakeRange(5, 0)]; + + // During composition, text should pass through unblocked + XCTAssertEqualObjects(result, @"additional", @"Text input should not be blocked during IME composition"); +} + +- (void)testMaxLengthEnforcedWhenNoComposition +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // No active composition + mockTextField.mockMarkedTextRange = nil; + + // With default props (maxLength = INT_MAX), text should pass through + NSString *result = + [(id)componentView textInputShouldChangeText:@"extra" inRange:NSMakeRange(5, 0)]; + + XCTAssertEqualObjects(result, @"extra", @"Text should pass through when maxLength is not constraining"); +} + +#pragma mark - Fix 4: multiline selection change bare text comparison + +- (void)testMultilineSelectionChangeUsesBarTextComparison +{ + // This test verifies that textInputDidChangeSelection uses string comparison + // (not NSAttributedString isEqual:) to avoid false positives during IME composition. + // + // When IME composition is active, the attributed string has system-added underline + // attributes that cause isEqual: to fail even when the bare text is identical. + // Using isEqualToString: on the bare text avoids triggering unnecessary + // textInputDidChange calls. + + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; + + // Set up attributed text with system-style attributes (simulating IME underline) + NSMutableAttributedString *textWithSystemAttrs = + [[NSMutableAttributedString alloc] initWithString:@"test" + attributes:@{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }]; + mockTextView.attributedText = textWithSystemAttrs; + [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; + mockTextView.textInputDelegate = (id)componentView; + + // Set _lastStringStateWasUpdatedWith to same text but different attributes + NSAttributedString *lastString = + [[NSAttributedString alloc] initWithString:@"test" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; + + // The bare text is the same ("test" == "test"), so even though the attributed + // strings differ (due to NSUnderlineStyleAttributeName), the comparison + // should return YES (equal) and NOT trigger an extra textInputDidChange. + XCTAssertTrue( + [lastString.string isEqualToString:mockTextView.attributedText.string], + @"Bare text comparison should show strings are equal"); + XCTAssertFalse( + [lastString isEqual:mockTextView.attributedText], + @"Full attributed string comparison should show strings are NOT equal (different attributes)"); +} + +- (void)testMultilineSelectionChangeNoExtraUpdateDuringComposition +{ + // Verifies that during IME composition, the attributed string underline + // attributes added by the system do not cause a spurious textInputDidChange call + // in multiline mode. + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; + + mockTextView.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; + [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; + mockTextView.textInputDelegate = (id)componentView; + + // Simulate active IME composition + mockTextView.mockMarkedTextRange = createMockTextRangeForTextView(mockTextView); + + // Set _lastStringStateWasUpdatedWith with same bare text + NSAttributedString *lastString = [[NSAttributedString alloc] initWithString:@"composing"]; + [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; + + // Since the bare text matches, textInputDidChangeSelection should NOT trigger textInputDidChange + // This is verified by the fact that _lastStringStateWasUpdatedWith.string equals attributedText.string + XCTAssertTrue( + [lastString.string isEqualToString:mockTextView.attributedText.string], + @"Bare text should match, preventing unnecessary textInputDidChange call"); +} + +@end From 537affb80485c0ec3fd989910bdf573817c4faac Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Tue, 17 Mar 2026 15:21:14 +0900 Subject: [PATCH 02/12] Fix additional IME composition edge cases in TextInput - Guard defaultTextAttributes in updateProps and traitCollectionDidChange to prevent breaking IME composition during JS-driven re-renders - Use grapheme-cluster-safe truncation in post-composition maxLength enforcement to avoid splitting emoji and composed characters - Skip JS-driven text updates entirely during IME composition in setTextAndSelection to prevent unnecessary state ping-pong --- .../TextInput/RCTTextInputComponentView.mm | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 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 00fff18147e0..ec8e866d92d8 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -160,8 +160,14 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection UITraitCollection.currentTraitCollection.preferredContentSizeCategory != previousTraitCollection.preferredContentSizeCategory) { const auto &newTextInputProps = static_cast(*_props); - _backedTextInputView.defaultTextAttributes = + NSDictionary *attributes = RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); + if (_backedTextInputView.markedTextRange) { + _needsUpdateDefaultTextAttributes = YES; + _pendingDefaultTextAttributes = [attributes copy]; + } else { + _backedTextInputView.defaultTextAttributes = attributes; + } } } @@ -314,7 +320,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); defaultAttributes[RCTAttributedStringEventEmitterKey] = _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; - _backedTextInputView.defaultTextAttributes = defaultAttributes; + if (_backedTextInputView.markedTextRange) { + _needsUpdateDefaultTextAttributes = YES; + _pendingDefaultTextAttributes = [defaultAttributes copy]; + } else { + _backedTextInputView.defaultTextAttributes = defaultAttributes; + } } if (newTextInputProps.selectionColor != oldTextInputProps.selectionColor) { @@ -532,11 +543,21 @@ - (void)textInputDidChange if (props.maxLength < std::numeric_limits::max()) { NSString *currentText = _backedTextInputView.attributedText.string; if ((NSInteger)currentText.length > props.maxLength) { - NSString *truncated = [currentText substringToIndex:props.maxLength]; - NSAttributedString *truncatedAttr = - [[NSAttributedString alloc] initWithString:truncated - attributes:_backedTextInputView.defaultTextAttributes]; - [self _setAttributedString:truncatedAttr]; + NSInteger truncateAt = props.maxLength; + // Ensure we don't split multi-codepoint characters (emoji, composed CJK, etc.) + if (truncateAt > 0) { + NSRange charRange = [currentText rangeOfComposedCharacterSequenceAtIndex:truncateAt - 1]; + if (charRange.location + charRange.length > (NSUInteger)truncateAt) { + truncateAt = charRange.location; + } + } + if (truncateAt > 0) { + NSString *truncated = [currentText substringToIndex:truncateAt]; + NSAttributedString *truncatedAttr = + [[NSAttributedString alloc] initWithString:truncated + attributes:_backedTextInputView.defaultTextAttributes]; + [self _setAttributedString:truncatedAttr]; + } } } } @@ -622,10 +643,15 @@ - (void)setTextAndSelection:(NSInteger)eventCount } _comingFromJS = YES; if (value && ![value isEqualToString:_backedTextInputView.attributedText.string]) { - NSAttributedString *attributedString = - [[NSAttributedString alloc] initWithString:value attributes:_backedTextInputView.defaultTextAttributes]; - [self _setAttributedString:attributedString]; - [self _updateState]; + if (!_backedTextInputView.markedTextRange) { + NSAttributedString *attributedString = + [[NSAttributedString alloc] initWithString:value attributes:_backedTextInputView.defaultTextAttributes]; + [self _setAttributedString:attributedString]; + [self _updateState]; + } + // During IME composition, skip JS-driven text updates entirely. + // The composition will commit via textInputDidChange, after which + // JS can re-assert its controlled value through a new setTextAndSelection call. } UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument From 4f9ea06a03e7a2183a28136c271ec05e25c5e7bc Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 11:39:01 +0900 Subject: [PATCH 03/12] Fix minor edge cases in IME composition handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prevent double textInputDidChange/onChange when post-composition maxLength truncation triggers _setAttributedString in multiline mode. The recursive call path was: _setAttributedString → setSelectedTextRange → textInputDidChangeSelection → textInputDidChange (recursive). Fixed by suppressing delegate callbacks during internal truncation. - Fix stale event emitter when updateEventEmitter and updateProps both defer during composition. updateProps now reads the event emitter from the pending dict if updateEventEmitter already deferred one, instead of reading the stale value from the view. - Improve test quality: testDefaultTextAttributesSkippedDuringMarkedText now actually verifies deferral behavior instead of only testing mock setup. Add documentation for maxLength test coverage limitations. --- .../TextInput/RCTTextInputComponentView.mm | 10 +++- .../RCTTextInputComponentViewIMETests.mm | 49 ++++++++++--------- 2 files changed, 36 insertions(+), 23 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 ec8e866d92d8..614f2867b3dd 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -318,8 +318,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & if (newTextInputProps.textAttributes != oldTextInputProps.textAttributes) { NSMutableDictionary *defaultAttributes = RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); + // If updateEventEmitter already deferred a pending update with the new event emitter, + // use it instead of the stale one on the view that hasn't been updated yet. + id pendingEmitter = _pendingDefaultTextAttributes[RCTAttributedStringEventEmitterKey]; defaultAttributes[RCTAttributedStringEventEmitterKey] = - _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; + pendingEmitter ?: _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; if (_backedTextInputView.markedTextRange) { _needsUpdateDefaultTextAttributes = YES; _pendingDefaultTextAttributes = [defaultAttributes copy]; @@ -556,7 +559,12 @@ - (void)textInputDidChange NSAttributedString *truncatedAttr = [[NSAttributedString alloc] initWithString:truncated attributes:_backedTextInputView.defaultTextAttributes]; + // Suppress delegate callbacks from _setAttributedString to prevent + // textInputDidChangeSelection from triggering a recursive textInputDidChange. + // This is an internal truncation, not a user or JS action. + _comingFromJS = YES; [self _setAttributedString:truncatedAttr]; + _comingFromJS = NO; } } } diff --git a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm index a5328ac3dc94..306427115034 100644 --- a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm +++ b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm @@ -82,33 +82,33 @@ @implementation RCTTextInputComponentViewIMETests - (void)testDefaultTextAttributesSkippedDuringMarkedText { - // Create the component view and swap in our mock text field + // Verifies that pending defaultTextAttributes are NOT applied while composition is active. + // textInputDidChange should only apply pending attributes after markedTextRange becomes nil. RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; - // Set initial text and attributes mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - NSDictionary *originalAttributes = @{NSFontAttributeName : [UIFont systemFontOfSize:14]}; - mockTextField.defaultTextAttributes = originalAttributes; - - // Swap the backed text input view [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; mockTextField.textInputDelegate = (id)componentView; - // Simulate IME composition active - UITextRange *range = createMockTextRange(mockTextField); - mockTextField.mockMarkedTextRange = range; + // Simulate deferred attributes (as if updateEventEmitter was called during composition) + NSDictionary *pendingAttributes = + @{NSFontAttributeName : [UIFont systemFontOfSize:18]}; + [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; + + // Composition IS active + mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); - // Store current attributes for comparison - NSDictionary *attributesBefore = [mockTextField.defaultTextAttributes copy]; + // Call textInputDidChange — pending attributes should NOT be applied yet + [(id)componentView textInputDidChange]; - // Trigger textInputDidChange (which would normally be called during typing) - // The key behavior: during IME composition, defaultTextAttributes should NOT be reapplied - // We verify this by checking the _needsUpdateDefaultTextAttributes flag via KVC - XCTAssertNotNil(mockTextField.markedTextRange, @"markedTextRange should be non-nil during composition"); + // Verify pending was preserved (not applied) because composition is still active + BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; + XCTAssertTrue(needsUpdate, @"_needsUpdateDefaultTextAttributes should remain YES during composition"); - // Verify that the mock properly reports markedTextRange - XCTAssertTrue(mockTextField.markedTextRange != nil, @"Mock should simulate active IME composition"); + id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; + XCTAssertNotNil(pendingAfter, @"_pendingDefaultTextAttributes should NOT be cleared during composition"); } - (void)testPendingDefaultTextAttributesAppliedAfterCompositionEnds @@ -207,10 +207,17 @@ - (void)testSetAttributedStringAppliedWhenNoMarkedText - (void)testMaxLengthNotEnforcedDuringComposition { + // Verifies that textInputShouldChangeText does not block input during IME composition, + // even if maxLength would normally restrict it. + // + // Note: maxLength is a C++ prop (TextInputProps) and cannot be set via KVC in unit tests. + // With defaultSharedProps, maxLength = INT_MAX, so the maxLength branch is never entered. + // This test verifies that the markedTextRange guard allows text through unconditionally + // during composition. The post-composition truncation path in textInputDidChange (which + // handles grapheme-cluster-safe truncation) requires integration testing with real props. RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; - // Set up text that's already at maxLength mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; mockTextField.textInputDelegate = (id)componentView; @@ -218,9 +225,6 @@ - (void)testMaxLengthNotEnforcedDuringComposition // Simulate active IME composition mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); - // Call textInputShouldChangeText during composition — maxLength should NOT block input - // Note: maxLength is set to default (INT_MAX) in defaultSharedProps, so this test verifies - // the guard condition only. When maxLength is set, the markedTextRange check prevents blocking. NSString *result = [(id)componentView textInputShouldChangeText:@"additional" inRange:NSMakeRange(5, 0)]; @@ -230,6 +234,8 @@ - (void)testMaxLengthNotEnforcedDuringComposition - (void)testMaxLengthEnforcedWhenNoComposition { + // Verifies that textInputShouldChangeText passes text through when maxLength is not constraining. + // See note in testMaxLengthNotEnforcedDuringComposition about prop limitations in unit tests. RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; @@ -240,7 +246,6 @@ - (void)testMaxLengthEnforcedWhenNoComposition // No active composition mockTextField.mockMarkedTextRange = nil; - // With default props (maxLength = INT_MAX), text should pass through NSString *result = [(id)componentView textInputShouldChangeText:@"extra" inRange:NSMakeRange(5, 0)]; From 4c59d9d2b8d1e7aa182665573057e92d1bbad5e7 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 11:48:23 +0900 Subject: [PATCH 04/12] Harden IME composition guards and add tests to rn-tester - Guard multiline textInputDidChangeSelection against triggering textInputDidChange during composition (markedTextRange check) - Change _ignoreNextTextInputCall guard from isEqual: to bare string comparison to prevent double onChange during composition in multiline - Preserve event emitter key in traitCollectionDidChange deferred path to prevent loss when traitCollectionDidChange fires between updateEventEmitter and updateProps during composition - Handle maxLength=0 edge case in post-composition truncation - Add test file to RNTesterUnitTests Xcode target (pbxproj) --- .../TextInput/RCTTextInputComponentView.mm | 18 +- .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTTextInputComponentViewIMETests.mm | 324 ++++++++++++++++++ 3 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.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 614f2867b3dd..23dc4cefbf18 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -160,9 +160,16 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection UITraitCollection.currentTraitCollection.preferredContentSizeCategory != previousTraitCollection.preferredContentSizeCategory) { const auto &newTextInputProps = static_cast(*_props); - NSDictionary *attributes = + NSMutableDictionary *attributes = RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier())); if (_backedTextInputView.markedTextRange) { + // Preserve the event emitter key from any prior pending update (e.g., from updateEventEmitter) + // or from the current view attributes, since RCTNSTextAttributesFromTextAttributes does not include it. + id emitter = _pendingDefaultTextAttributes[RCTAttributedStringEventEmitterKey] + ?: _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; + if (emitter) { + attributes[RCTAttributedStringEventEmitterKey] = emitter; + } _needsUpdateDefaultTextAttributes = YES; _pendingDefaultTextAttributes = [attributes copy]; } else { @@ -525,7 +532,8 @@ - (void)textInputDidChange return; } - if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { + if (_ignoreNextTextInputCall && + [_lastStringStateWasUpdatedWith.string isEqualToString:_backedTextInputView.attributedText.string]) { _ignoreNextTextInputCall = NO; return; } @@ -554,8 +562,8 @@ - (void)textInputDidChange truncateAt = charRange.location; } } - if (truncateAt > 0) { - NSString *truncated = [currentText substringToIndex:truncateAt]; + { + NSString *truncated = truncateAt > 0 ? [currentText substringToIndex:truncateAt] : @""; NSAttributedString *truncatedAttr = [[NSAttributedString alloc] initWithString:truncated attributes:_backedTextInputView.defaultTextAttributes]; @@ -590,7 +598,7 @@ - (void)textInputDidChangeSelection [self _updateTypingAttributes]; const auto &props = static_cast(*_props); - if (props.multiline && + if (props.multiline && !_backedTextInputView.markedTextRange && ![_lastStringStateWasUpdatedWith.string isEqualToString:_backedTextInputView.attributedText.string]) { [self textInputDidChange]; _ignoreNextTextInputCall = YES; diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 898cd14e1ca5..a0f19e957004 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; }; A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */; }; C175B6D9ED9336FB66637943 /* libPods-RNTester.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C706D402EE4AF9BE838CBA9 /* libPods-RNTester.a */; }; + 5C175C1C2966CE7C263BDE88 /* RCTTextInputComponentViewIMETests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 8D48EA169B37CE411049B4C1 /* RCTTextInputComponentViewIMETests.mm */; }; CD10C7A5290BD4EB0033E1ED /* RCTEventEmitterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */; }; E62F11832A5C6580000BF1C8 /* FlexibleSizeExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 27F441E81BEBE5030039B79C /* FlexibleSizeExampleView.mm */; }; E62F11842A5C6584000BF1C8 /* UpdatePropertiesExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 272E6B3C1BEA849E001FCF37 /* UpdatePropertiesExampleView.mm */; }; @@ -98,6 +99,7 @@ AC474BFB29BBD4A1002BDAED /* RNTester.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = RNTester.xctestplan; path = RNTester/RNTester.xctestplan; sourceTree = ""; }; B0E70A8A05E03E868F8703FE /* Pods-RNTesterIntegrationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterIntegrationTests.release.xcconfig"; path = "Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests.release.xcconfig"; sourceTree = ""; }; CA59C9994B1822826D8983F0 /* Pods-RNTester.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.debug.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.debug.xcconfig"; sourceTree = ""; }; + 8D48EA169B37CE411049B4C1 /* RCTTextInputComponentViewIMETests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTextInputComponentViewIMETests.mm; sourceTree = ""; }; CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTEventEmitterTests.m; sourceTree = ""; }; D134EB89DD98253FCF879A47 /* Pods-RNTesterUnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterUnitTests.debug.xcconfig"; path = "Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests.debug.xcconfig"; sourceTree = ""; }; E771AEEA22B44E3100EA1189 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNTester/Info.plist; sourceTree = ""; }; @@ -336,6 +338,7 @@ A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */, E7DB20BE22B2BAA4005AC45F /* RCTNativeAnimatedNodesManagerTests.m */, E7DB20AD22B2BAA3005AC45F /* RCTPerformanceLoggerTests.m */, + 8D48EA169B37CE411049B4C1 /* RCTTextInputComponentViewIMETests.mm */, E7DB20C122B2BAA4005AC45F /* RCTUnicodeDecodeTests.m */, E7DB20D022B2BAA5005AC45F /* RCTURLUtilsTests.m */, E7DB20B322B2BAA4005AC45F /* RNTesterUnitTestsBundle.js */, @@ -764,6 +767,7 @@ E7DB20EB22B2BAA6005AC45F /* RCTConvert_YGValueTests.m in Sources */, E7DB20E922B2BAA6005AC45F /* RCTComponentPropsTests.m in Sources */, E7DB20D822B2BAA6005AC45F /* RCTJSONTests.m in Sources */, + 5C175C1C2966CE7C263BDE88 /* RCTTextInputComponentViewIMETests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm new file mode 100644 index 000000000000..306427115034 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm @@ -0,0 +1,324 @@ +/* + * 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 "RCTTextInputComponentView.h" + +/** + * Mock UITextField subclass that allows overriding markedTextRange for testing + * IME composition scenarios without requiring actual keyboard input. + */ +@interface RCTMockTextField : RCTUITextField +@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; +@end + +@implementation RCTMockTextField + +- (UITextRange *)markedTextRange +{ + return _mockMarkedTextRange; +} + +@end + +/** + * Mock UITextView subclass for multiline IME composition testing. + */ +@interface RCTMockTextView : RCTUITextView +@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; +@end + +@implementation RCTMockTextView + +- (UITextRange *)markedTextRange +{ + return _mockMarkedTextRange; +} + +@end + +/** + * Helper to create a non-nil UITextRange for simulating IME composition. + */ +static UITextRange *createMockTextRange(UITextField *textField) +{ + UITextPosition *start = textField.beginningOfDocument; + UITextPosition *end = [textField positionFromPosition:start offset:1]; + if (!end) { + end = start; + } + return [textField textRangeFromPosition:start toPosition:end ?: start]; +} + +static UITextRange *createMockTextRangeForTextView(UITextView *textView) +{ + UITextPosition *start = textView.beginningOfDocument; + UITextPosition *end = [textView positionFromPosition:start offset:1]; + if (!end) { + end = start; + } + return [textView textRangeFromPosition:start toPosition:end ?: start]; +} + +#pragma mark - Test class + +@interface RCTTextInputComponentViewIMETests : XCTestCase + +@end + +@implementation RCTTextInputComponentViewIMETests + +#pragma mark - Fix 1: updateEventEmitter defaultTextAttributes deferral + +- (void)testDefaultTextAttributesSkippedDuringMarkedText +{ + // Verifies that pending defaultTextAttributes are NOT applied while composition is active. + // textInputDidChange should only apply pending attributes after markedTextRange becomes nil. + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Simulate deferred attributes (as if updateEventEmitter was called during composition) + NSDictionary *pendingAttributes = + @{NSFontAttributeName : [UIFont systemFontOfSize:18]}; + [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; + + // Composition IS active + mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + + // Call textInputDidChange — pending attributes should NOT be applied yet + [(id)componentView textInputDidChange]; + + // Verify pending was preserved (not applied) because composition is still active + BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; + XCTAssertTrue(needsUpdate, @"_needsUpdateDefaultTextAttributes should remain YES during composition"); + + id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; + XCTAssertNotNil(pendingAfter, @"_pendingDefaultTextAttributes should NOT be cleared during composition"); +} + +- (void)testPendingDefaultTextAttributesAppliedAfterCompositionEnds +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Set pending attributes (simulating what updateEventEmitter would do) + NSDictionary *pendingAttributes = + @{NSFontAttributeName : [UIFont systemFontOfSize:16]}; + [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; + + // Composition is NOT active (markedTextRange is nil) + mockTextField.mockMarkedTextRange = nil; + + // Call textInputDidChange — should apply pending attributes + [(id)componentView textInputDidChange]; + + // After textInputDidChange with no markedText, pending should be cleared + BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; + XCTAssertFalse(needsUpdate, @"_needsUpdateDefaultTextAttributes should be cleared after applying"); + + id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; + XCTAssertNil(pendingAfter, @"_pendingDefaultTextAttributes should be nil after applying"); +} + +#pragma mark - Fix 2: _setAttributedString guard during composition + +- (void)testSetAttributedStringSkippedDuringMarkedText +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; + mockTextField.attributedText = originalText; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Simulate active IME composition + mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + + // Try to set a different attributed string via the private method + NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; + // Use performSelector to call private method _setAttributedString: +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL selector = NSSelectorFromString(@"_setAttributedString:"); + if ([componentView respondsToSelector:selector]) { + [componentView performSelector:selector withObject:newText]; + } +#pragma clang diagnostic pop + + // The text should remain unchanged because markedTextRange is active + XCTAssertEqualObjects( + mockTextField.attributedText.string, + @"original", + @"attributedText should not be replaced during IME composition"); +} + +- (void)testSetAttributedStringAppliedWhenNoMarkedText +{ + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; + mockTextField.attributedText = originalText; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // No IME composition + mockTextField.mockMarkedTextRange = nil; + + // Set a different attributed string + NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL selector = NSSelectorFromString(@"_setAttributedString:"); + if ([componentView respondsToSelector:selector]) { + [componentView performSelector:selector withObject:newText]; + } +#pragma clang diagnostic pop + + // The text should be updated since there's no active composition + XCTAssertEqualObjects( + mockTextField.attributedText.string, + @"replaced", + @"attributedText should be replaced when no IME composition is active"); +} + +#pragma mark - Fix 3: maxLength during IME composition + +- (void)testMaxLengthNotEnforcedDuringComposition +{ + // Verifies that textInputShouldChangeText does not block input during IME composition, + // even if maxLength would normally restrict it. + // + // Note: maxLength is a C++ prop (TextInputProps) and cannot be set via KVC in unit tests. + // With defaultSharedProps, maxLength = INT_MAX, so the maxLength branch is never entered. + // This test verifies that the markedTextRange guard allows text through unconditionally + // during composition. The post-composition truncation path in textInputDidChange (which + // handles grapheme-cluster-safe truncation) requires integration testing with real props. + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // Simulate active IME composition + mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + + NSString *result = + [(id)componentView textInputShouldChangeText:@"additional" inRange:NSMakeRange(5, 0)]; + + // During composition, text should pass through unblocked + XCTAssertEqualObjects(result, @"additional", @"Text input should not be blocked during IME composition"); +} + +- (void)testMaxLengthEnforcedWhenNoComposition +{ + // Verifies that textInputShouldChangeText passes text through when maxLength is not constraining. + // See note in testMaxLengthNotEnforcedDuringComposition about prop limitations in unit tests. + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + + mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; + mockTextField.textInputDelegate = (id)componentView; + + // No active composition + mockTextField.mockMarkedTextRange = nil; + + NSString *result = + [(id)componentView textInputShouldChangeText:@"extra" inRange:NSMakeRange(5, 0)]; + + XCTAssertEqualObjects(result, @"extra", @"Text should pass through when maxLength is not constraining"); +} + +#pragma mark - Fix 4: multiline selection change bare text comparison + +- (void)testMultilineSelectionChangeUsesBarTextComparison +{ + // This test verifies that textInputDidChangeSelection uses string comparison + // (not NSAttributedString isEqual:) to avoid false positives during IME composition. + // + // When IME composition is active, the attributed string has system-added underline + // attributes that cause isEqual: to fail even when the bare text is identical. + // Using isEqualToString: on the bare text avoids triggering unnecessary + // textInputDidChange calls. + + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; + + // Set up attributed text with system-style attributes (simulating IME underline) + NSMutableAttributedString *textWithSystemAttrs = + [[NSMutableAttributedString alloc] initWithString:@"test" + attributes:@{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }]; + mockTextView.attributedText = textWithSystemAttrs; + [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; + mockTextView.textInputDelegate = (id)componentView; + + // Set _lastStringStateWasUpdatedWith to same text but different attributes + NSAttributedString *lastString = + [[NSAttributedString alloc] initWithString:@"test" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; + + // The bare text is the same ("test" == "test"), so even though the attributed + // strings differ (due to NSUnderlineStyleAttributeName), the comparison + // should return YES (equal) and NOT trigger an extra textInputDidChange. + XCTAssertTrue( + [lastString.string isEqualToString:mockTextView.attributedText.string], + @"Bare text comparison should show strings are equal"); + XCTAssertFalse( + [lastString isEqual:mockTextView.attributedText], + @"Full attributed string comparison should show strings are NOT equal (different attributes)"); +} + +- (void)testMultilineSelectionChangeNoExtraUpdateDuringComposition +{ + // Verifies that during IME composition, the attributed string underline + // attributes added by the system do not cause a spurious textInputDidChange call + // in multiline mode. + RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; + + mockTextView.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; + [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; + mockTextView.textInputDelegate = (id)componentView; + + // Simulate active IME composition + mockTextView.mockMarkedTextRange = createMockTextRangeForTextView(mockTextView); + + // Set _lastStringStateWasUpdatedWith with same bare text + NSAttributedString *lastString = [[NSAttributedString alloc] initWithString:@"composing"]; + [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; + + // Since the bare text matches, textInputDidChangeSelection should NOT trigger textInputDidChange + // This is verified by the fact that _lastStringStateWasUpdatedWith.string equals attributedText.string + XCTAssertTrue( + [lastString.string isEqualToString:mockTextView.attributedText.string], + @"Bare text should match, preventing unnecessary textInputDidChange call"); +} + +@end From 92329e1ff6c57607b4da055cbfbc9f4e4cdd99ba Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 12:08:19 +0900 Subject: [PATCH 05/12] Add JS-level tests for TextInput IME composition behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test controlled/uncontrolled components with CJK composition event patterns (Korean ㅎ→하→한, Japanese か→漢字), multiline composition, maxLength interaction, and value prop updates during composition. All 7 tests pass. Existing 26 TextInput tests unaffected. --- .../TextInput/__tests__/TextInput-ime-test.js | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js new file mode 100644 index 000000000000..86ca877da7be --- /dev/null +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js @@ -0,0 +1,312 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +const {enter} = require('../../../Utilities/ReactNativeTestTools'); +const TextInput = require('../TextInput').default; +const {create} = require('@react-native/jest-preset/jest/renderer'); +const React = require('react'); +const {createRef, useState} = require('react'); +const ReactTestRenderer = require('react-test-renderer'); + +jest.unmock('../TextInput'); + +/** + * Tests that verify JS-level TextInput behavior during IME composition + * scenarios. The actual IME guards (markedTextRange checks, deferred + * defaultTextAttributes, etc.) are in the native layer and tested by + * RCTTextInputComponentViewIMETests.mm. These tests verify that the JS + * component correctly handles the event patterns produced by native + * during CJK composition. + */ +describe('TextInput IME composition behavior', () => { + describe('controlled component with CJK composition', () => { + it('handles intermediate composition text via onChange', () => { + // Simulates Korean IME: ㅎ → 하 → 한 → 한글 + // Each step fires onChange from native. The controlled component should + // update its value at each step without losing state. + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // Simulate Korean composition steps + const compositionSteps = ['ㅎ', '하', '한', '한글']; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(4); + expect(onChangeText.mock.calls.map(c => c[0])).toEqual( + compositionSteps, + ); + expect(currentText).toBe('한글'); + }); + + it('handles Japanese composition with conversion', () => { + // Simulates Japanese IME: か → かん → 漢 → 漢字 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + const compositionSteps = ['か', 'かん', '漢', '漢字']; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(4); + expect(currentText).toBe('漢字'); + }); + + it('preserves existing text when composition appends', () => { + // User types "hello" then starts CJK composition: "hello" → "helloㅎ" → "hello하" → "hello한" + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState('hello'); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + ReactTestRenderer.act(() => { + enter(input, 'helloㅎ'); + }); + ReactTestRenderer.act(() => { + enter(input, 'hello하'); + }); + ReactTestRenderer.act(() => { + enter(input, 'hello한'); + }); + + expect(onChangeText).toHaveBeenCalledTimes(3); + expect(currentText).toBe('hello한'); + }); + }); + + describe('controlled component with maxLength and CJK', () => { + it('allows composition text through onChange even at maxLength boundary', () => { + // With maxLength=5 and existing text "1234", native allows composition + // past the limit during IME (enforced after commit). The JS side should + // receive the full text from native's onChange event. + const onChangeText = jest.fn(); + + function ControlledInput() { + const [text, setText] = useState('1234'); + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // During composition, native sends text that may temporarily exceed maxLength. + // JS receives it as-is from native onChange. + ReactTestRenderer.act(() => { + enter(input, '1234ㅎ'); + }); + expect(onChangeText).toHaveBeenLastCalledWith('1234ㅎ'); + + // After composition commits, native truncates and sends final text. + ReactTestRenderer.act(() => { + enter(input, '1234한'); + }); + expect(onChangeText).toHaveBeenLastCalledWith('1234한'); + + // Native may send truncated text after enforcing maxLength post-commit. + ReactTestRenderer.act(() => { + enter(input, '12345'); + }); + expect(onChangeText).toHaveBeenLastCalledWith('12345'); + }); + }); + + describe('uncontrolled component with CJK composition', () => { + it('fires onChange and onChangeText during composition', () => { + const onChange = jest.fn(); + const onChangeText = jest.fn(); + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + , + ); + }); + + const input = renderer.root.findByType(TextInput); + + ReactTestRenderer.act(() => { + enter(input, 'ㅎ'); + }); + ReactTestRenderer.act(() => { + enter(input, '한'); + }); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChangeText).toHaveBeenCalledTimes(2); + expect(onChangeText.mock.calls).toEqual([['ㅎ'], ['한']]); + }); + }); + + describe('multiline with CJK composition', () => { + it('handles composition in multiline mode', () => { + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledMultiline() { + const [text, setText] = useState('line1\n'); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // Composition on second line + ReactTestRenderer.act(() => { + enter(input, 'line1\nㅎ'); + }); + ReactTestRenderer.act(() => { + enter(input, 'line1\n한'); + }); + ReactTestRenderer.act(() => { + enter(input, 'line1\n한글'); + }); + + expect(onChangeText).toHaveBeenCalledTimes(3); + expect(currentText).toBe('line1\n한글'); + }); + }); + + describe('value prop change during simulated composition', () => { + it('controlled component can set value after composition text arrives', () => { + // Simulates: native fires onChange with composition text, then JS + // transforms the text (e.g., uppercase) and sets it back. + const ref = createRef>(); + + function TransformingInput() { + const [text, setText] = useState(''); + return ( + { + // Transform: just accept the text as-is + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // Composition intermediate + ReactTestRenderer.act(() => { + enter(input, '한'); + }); + + expect(input.props.value).toBe('한'); + + // Final committed text + ReactTestRenderer.act(() => { + enter(input, '한글'); + }); + + expect(input.props.value).toBe('한글'); + }); + }); +}); From d3cd20834abfc44889a17dcdacdc44c8fa0d2341 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 12:11:18 +0900 Subject: [PATCH 06/12] Add Japanese/Chinese romaji/pinyin composition test cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Japanese romaji→hiragana (kanji), romaji→katakana (東京), candidate re-selection (橋→箸→端→箸) - Chinese Pinyin (zhongguo→中国), Wubi stroke (ggtt→王), Zhuyin/Bopomofo (ㄓㄨㄥ→中) - Korean multi-syllable (감사합니다) - Mixed Latin↔CJK switching mid-sentence - Continuous sentence composition (私は学生です, 我爱中国) --- .../TextInput/__tests__/TextInput-ime-test.js | 575 +++++++++++++++++- 1 file changed, 571 insertions(+), 4 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js index 86ca877da7be..b7ca98989b97 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js @@ -70,8 +70,9 @@ describe('TextInput IME composition behavior', () => { expect(currentText).toBe('한글'); }); - it('handles Japanese composition with conversion', () => { - // Simulates Japanese IME: か → かん → 漢 → 漢字 + it('handles Japanese romaji-to-hiragana conversion', () => { + // Japanese IME romaji input: typing "k","a","n","j","i" produces: + // k → か (ka) → かn → かん (kan) → かんj → かんじ (kanji) → 漢字 (kanji conversion) const onChangeText = jest.fn(); let currentText = ''; @@ -96,17 +97,295 @@ describe('TextInput IME composition behavior', () => { const input = renderer.root.findByType(TextInput); - const compositionSteps = ['か', 'かん', '漢', '漢字']; + // Romaji "kanji" → hiragana → kanji conversion + const compositionSteps = [ + 'k', // raw romaji, still composing + 'か', // "ka" converted to hiragana + 'かn', // next consonant buffered + 'かん', // "kan" complete + 'かんj', // next consonant buffered + 'かんじ', // "kanji" in hiragana + '漢字', // user selected kanji conversion from candidate list + ]; compositionSteps.forEach(step => { ReactTestRenderer.act(() => { enter(input, step); }); }); - expect(onChangeText).toHaveBeenCalledTimes(4); + expect(onChangeText).toHaveBeenCalledTimes(7); expect(currentText).toBe('漢字'); }); + it('handles Japanese romaji-to-katakana conversion', () => { + // Some IME modes convert romaji directly to katakana: + // "to" → "と" → "トウキョウ" → "東京" (or user keeps katakana) + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + const compositionSteps = [ + 't', + 'と', // "to" → hiragana + 'とう', + 'とうk', + 'とうき', + 'とうきょ', + 'とうきょう', + '東京', // kanji conversion selected + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(8); + expect(currentText).toBe('東京'); + }); + + it('handles Japanese composition with candidate re-selection', () => { + // User types "hashi", gets 橋, re-selects to 箸, then confirms + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + const compositionSteps = [ + 'h', + 'は', + 'はs', + 'はし', // "hashi" in hiragana + '橋', // first candidate + '箸', // user scrolls to different candidate + '端', // another candidate + '箸', // user goes back and confirms + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(8); + expect(currentText).toBe('箸'); + }); + + it('handles Chinese Pinyin composition', () => { + // Chinese Pinyin IME: typing "zhongguo" produces: + // z → zh → zho → zhon → zhong → 中 (selected from candidates) + // Then "guo" → 国 → 中国 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // First character: "zhong" → 中 + const firstChar = ['z', 'zh', 'zho', 'zhon', 'zhong', '中']; + firstChar.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(currentText).toBe('中'); + + // Second character: "guo" → 国, appended after 中 + const secondChar = ['中g', '中gu', '中guo', '中国']; + secondChar.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(10); + expect(currentText).toBe('中国'); + }); + + it('handles Chinese Wubi stroke-based input', () => { + // Wubi IME uses letter keys as stroke codes: e.g., "ggtt" → 王 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // Wubi input for 王 (wang/king) + const compositionSteps = ['g', 'gg', 'ggt', 'ggtt', '王']; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(5); + expect(currentText).toBe('王'); + }); + + it('handles Chinese Zhuyin (Bopomofo) input', () => { + // Zhuyin IME (used in Taiwan): ㄓㄨㄥ → 中 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + const compositionSteps = ['ㄓ', 'ㄓㄨ', 'ㄓㄨㄥ', '中']; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(4); + expect(currentText).toBe('中'); + }); + + it('handles Korean multi-syllable composition', () => { + // Korean IME builds syllables incrementally: + // ㄱ → 가 → 감 → 감ㅅ → 감사 → 감사ㅎ → 감사하 → 감사합 → 감사합ㄴ → 감사합니 → 감사합니ㄷ → 감사합니다 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + const compositionSteps = [ + 'ㄱ', + '가', + '감', + '감ㅅ', + '감사', + '감사ㅎ', + '감사하', + '감사합', + '감사합ㄴ', + '감사합니', + '감사합니ㄷ', + '감사합니다', + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(onChangeText).toHaveBeenCalledTimes(12); + expect(currentText).toBe('감사합니다'); + }); + it('preserves existing text when composition appends', () => { // User types "hello" then starts CJK composition: "hello" → "helloㅎ" → "hello하" → "hello한" const onChangeText = jest.fn(); @@ -267,6 +546,294 @@ describe('TextInput IME composition behavior', () => { }); }); + describe('mixed Latin and CJK input', () => { + it('handles switching from Latin to Japanese mid-sentence', () => { + // User types "Hello " in Latin, then switches to Japanese IME + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // Latin portion typed directly + ReactTestRenderer.act(() => { + enter(input, 'Hello '); + }); + + // Japanese IME activated, typing "sekai" → 世界 + const compositionSteps = [ + 'Hello s', + 'Hello せ', + 'Hello せk', + 'Hello せか', + 'Hello せかi', + 'Hello せかい', + 'Hello 世界', + ]; + compositionSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(currentText).toBe('Hello 世界'); + }); + + it('handles switching from Chinese to Latin mid-sentence', () => { + // User types Chinese first, then switches to Latin + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // Chinese Pinyin: "ni" → 你 + ['n', 'ni', '你'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Chinese Pinyin: "hao" → 好 + ['你h', '你ha', '你hao', '你好'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Switch to Latin and type directly + ReactTestRenderer.act(() => { + enter(input, '你好 World'); + }); + + expect(currentText).toBe('你好 World'); + }); + + it('handles Korean input between Latin words', () => { + // "React 네이티브 is great" + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // "React " typed in Latin + ReactTestRenderer.act(() => { + enter(input, 'React '); + }); + + // Korean composition: 네이티브 + const koreanSteps = [ + 'React ㄴ', + 'React 네', + 'React 네ㅇ', + 'React 네이', + 'React 네이ㅌ', + 'React 네이티', + 'React 네이티ㅂ', + 'React 네이티브', + ]; + koreanSteps.forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Back to Latin + ReactTestRenderer.act(() => { + enter(input, 'React 네이티브 is great'); + }); + + expect(currentText).toBe('React 네이티브 is great'); + }); + }); + + describe('continuous CJK sentence composition', () => { + it('handles Japanese sentence with multiple conversions', () => { + // Typing "watashiha" → 私は, then "gakusei" → 学生, then "desu" → です + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // First word: watashi → 私 + ['w', 'わ', 'わt', 'わた', 'わたs', 'わたし', '私'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Particle: ha → は + ['私h', '私は'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Second word: gakusei → 学生 + [ + '私はg', + '私はが', + '私はがk', + '私はがく', + '私はがくs', + '私はがくせ', + '私はがくせi', + '私はがくせい', + '私は学生', + ].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // Copula: desu → です + ['私は学生d', '私は学生で', '私は学生でs', '私は学生です'].forEach( + step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }, + ); + + expect(currentText).toBe('私は学生です'); + }); + + it('handles Chinese Pinyin sentence input', () => { + // Typing "wo ai zhongguo" → 我爱中国 + const onChangeText = jest.fn(); + let currentText = ''; + + function ControlledInput() { + const [text, setText] = useState(''); + currentText = text; + return ( + { + onChangeText(t); + setText(t); + }} + /> + ); + } + + let renderer: $FlowFixMe; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create(); + }); + + const input = renderer.root.findByType(TextInput); + + // "wo" → 我 + ['w', 'wo', '我'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // "ai" → 爱 + ['我a', '我ai', '我爱'].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + // "zhongguo" → 中国 + [ + '我爱z', + '我爱zh', + '我爱zho', + '我爱zhon', + '我爱zhong', + '我爱中', + '我爱中g', + '我爱中gu', + '我爱中guo', + '我爱中国', + ].forEach(step => { + ReactTestRenderer.act(() => { + enter(input, step); + }); + }); + + expect(currentText).toBe('我爱中国'); + }); + }); + describe('value prop change during simulated composition', () => { it('controlled component can set value after composition text arrives', () => { // Simulates: native fires onChange with composition text, then JS From a6954333a98ece50cff238aa90e484c1497a1c89 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 14:49:24 +0900 Subject: [PATCH 07/12] Rewrite native IME tests as lifecycle simulations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace individual guard-condition tests with full composition lifecycle tests that call UIKit delegate methods in the exact order they fire during real IME input. Remove Maestro E2E test (inputText bypasses IME). Lifecycle tests added: - Korean: ㅎ→하→한 (single syllable), 감→감ㅅ→감사 (multi-syllable) - Japanese: k→か→かん→漢 (romaji), はし→橋→箸→端→箸 (candidate re-selection) - Chinese: z→zh→zhong→中 (Pinyin), ㄓㄨㄥ→中 (Zhuyin/Bopomofo) - Mixed: "Hello" + Japanese IME → "Hello世界" Guard tests retained: - _setAttributedString blocked/allowed - Deferred defaultTextAttributes preserved/applied - maxLength bypassed during composition - Multiline bare text comparison - JS-driven update blocked during composition 16 tests, all passing. --- .../RCTTextInputComponentViewIMETests.mm | 554 +++++++++++------ .../RCTTextInputComponentViewIMETests.mm | 556 ++++++++++++------ 2 files changed, 763 insertions(+), 347 deletions(-) diff --git a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm index 306427115034..a4d665fafc19 100644 --- a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm +++ b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm @@ -78,247 +78,455 @@ @interface RCTTextInputComponentViewIMETests : XCTestCase @implementation RCTTextInputComponentViewIMETests -#pragma mark - Fix 1: updateEventEmitter defaultTextAttributes deferral +#pragma mark - Helpers -- (void)testDefaultTextAttributesSkippedDuringMarkedText +- (RCTTextInputComponentView *)createSingleLineWithMock:(RCTMockTextField **)outMock { - // Verifies that pending defaultTextAttributes are NOT applied while composition is active. - // textInputDidChange should only apply pending attributes after markedTextRange becomes nil. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mock = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + [cv setValue:mock forKey:@"_backedTextInputView"]; + mock.textInputDelegate = (id)cv; + if (outMock) { + *outMock = mock; + } + return cv; +} - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; +- (RCTTextInputComponentView *)createMultiLineWithMock:(RCTMockTextView **)outMock +{ + RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextView *mock = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; + [cv setValue:mock forKey:@"_backedTextInputView"]; + mock.textInputDelegate = (id)cv; + if (outMock) { + *outMock = mock; + } + return cv; +} - // Simulate deferred attributes (as if updateEventEmitter was called during composition) - NSDictionary *pendingAttributes = - @{NSFontAttributeName : [UIFont systemFontOfSize:18]}; - [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; +#pragma mark - Korean IME lifecycle - // Composition IS active - mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); +- (void)testKoreanCompositionLifecycle +{ + // Simulates the full UIKit delegate call sequence for typing "한": + // ㅎ (composing) → 하 (composing) → 한 (composing) → 한 (committed) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ulAttrs = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Step 1: ㅎ — composition starts + mock.mockMarkedTextRange = nil; + [delegate textInputShouldChangeText:@"ㅎ" inRange:NSMakeRange(0, 0)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ulAttrs]; + mock.mockMarkedTextRange = createMockTextRange(mock); + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"ㅎ"); + + // Step 2: 하 — vowel added + [delegate textInputShouldChangeText:@"하" inRange:NSMakeRange(0, 1)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"하" attributes:ulAttrs]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"하"); + + // Step 3: 한 — final consonant added + [delegate textInputShouldChangeText:@"한" inRange:NSMakeRange(0, 1)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ulAttrs]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"한"); + + // Step 4: commit — markedTextRange cleared, underline removed + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"한"); +} - // Call textInputDidChange — pending attributes should NOT be applied yet - [(id)componentView textInputDidChange]; +#pragma mark - Japanese romaji lifecycle + +- (void)testJapaneseRomajiCompositionLifecycle +{ + // k → か → かn → かん → 漢 (candidate) → commit + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"k", @"か", @"かn", @"かん" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } + + // Candidate selection + mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"漢"); +} + +#pragma mark - Chinese Pinyin lifecycle + +- (void)testChinesePinyinCompositionLifecycle +{ + // z → zh → zhong → 中 (candidate) → commit + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"z", @"zh", @"zho", @"zhon", @"zhong" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } - // Verify pending was preserved (not applied) because composition is still active - BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; - XCTAssertTrue(needsUpdate, @"_needsUpdateDefaultTextAttributes should remain YES during composition"); + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; + [delegate textInputDidChange]; - id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; - XCTAssertNotNil(pendingAfter, @"_pendingDefaultTextAttributes should NOT be cleared during composition"); + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"中"); } -- (void)testPendingDefaultTextAttributesAppliedAfterCompositionEnds +#pragma mark - Korean multi-syllable lifecycle + +- (void)testKoreanMultiSyllableCompositionLifecycle { - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + // 감사 — two syllables: ㄱ→가→감 (commit) → ㅅ→사 (commit) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // First syllable: 감 + mock.mockMarkedTextRange = createMockTextRange(mock); + for (NSString *step in @[ @"ㄱ", @"가", @"감" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } + + // ㅅ is typed — 감 commits, new syllable starts: 감ㅅ + // System splits: "감" committed + "ㅅ" composing + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감ㅅ" attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"감ㅅ"); - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; + // ㅏ added: 감사 + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사" attributes:ul]; + [delegate textInputDidChange]; - // Set pending attributes (simulating what updateEventEmitter would do) - NSDictionary *pendingAttributes = - @{NSFontAttributeName : [UIFont systemFontOfSize:16]}; - [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"감사"); +} - // Composition is NOT active (markedTextRange is nil) - mockTextField.mockMarkedTextRange = nil; +#pragma mark - Japanese candidate re-selection lifecycle - // Call textInputDidChange — should apply pending attributes - [(id)componentView textInputDidChange]; +- (void)testJapaneseCandidateReselectionLifecycle +{ + // はし → 橋 → 箸 → 端 → 箸 (user scrolls candidates and confirms) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Romaji input + for (NSString *step in @[ @"h", @"は", @"はs", @"はし" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } + + // Candidate selection — user scrolls through candidates + for (NSString *candidate in @[ @"橋", @"箸", @"端", @"箸" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:candidate attributes:ul]; + [delegate textInputDidChange]; + // Text should reflect the current candidate, not be replaced by JS + XCTAssertEqualObjects(mock.attributedText.string, candidate); + } - // After textInputDidChange with no markedText, pending should be cleared - BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; - XCTAssertFalse(needsUpdate, @"_needsUpdateDefaultTextAttributes should be cleared after applying"); + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"箸"]; + [delegate textInputDidChange]; - id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; - XCTAssertNil(pendingAfter, @"_pendingDefaultTextAttributes should be nil after applying"); + XCTAssertEqualObjects(mock.attributedText.string, @"箸"); } -#pragma mark - Fix 2: _setAttributedString guard during composition +#pragma mark - Chinese Zhuyin (Bopomofo) lifecycle -- (void)testSetAttributedStringSkippedDuringMarkedText +- (void)testChineseZhuyinCompositionLifecycle { - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + // Zhuyin (Bopomofo) IME used in Taiwan: ㄓㄨㄥ → 中 + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"ㄓ", @"ㄓㄨ", @"ㄓㄨㄥ" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } + + // Candidate selected + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"中"); +} + +#pragma mark - Mixed Latin + CJK lifecycle - NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; - mockTextField.attributedText = originalText; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; +- (void)testMixedLatinAndCJKComposition +{ + // User types "Hello" in Latin, then switches to Japanese IME and types "世界" + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Latin input (no composition) + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; + [delegate textInputDidChange]; + + // Japanese composition starts after "Hello" + mock.mockMarkedTextRange = createMockTextRange(mock); + for (NSString *step in @[ @"Hellos", @"Helloせ", @"Helloせk", @"Helloせか", @"Helloせかi", @"Helloせかい" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } - // Simulate active IME composition - mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + // Candidate selected + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"Hello世界"); +} + +#pragma mark - _setAttributedString guard + +- (void)testSetAttributedStringBlockedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; + mock.mockMarkedTextRange = createMockTextRange(mock); - // Try to set a different attributed string via the private method - NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; - // Use performSelector to call private method _setAttributedString: #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL selector = NSSelectorFromString(@"_setAttributedString:"); - if ([componentView respondsToSelector:selector]) { - [componentView performSelector:selector withObject:newText]; + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"replaced"]]; } #pragma clang diagnostic pop - // The text should remain unchanged because markedTextRange is active - XCTAssertEqualObjects( - mockTextField.attributedText.string, - @"original", - @"attributedText should not be replaced during IME composition"); + XCTAssertEqualObjects(mock.attributedText.string, @"composing", + @"Text must not be replaced during composition"); } -- (void)testSetAttributedStringAppliedWhenNoMarkedText +- (void)testSetAttributedStringAllowedAfterComposition { - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; - mockTextField.attributedText = originalText; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"old"]; + mock.mockMarkedTextRange = nil; - // No IME composition - mockTextField.mockMarkedTextRange = nil; - - // Set a different attributed string - NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL selector = NSSelectorFromString(@"_setAttributedString:"); - if ([componentView respondsToSelector:selector]) { - [componentView performSelector:selector withObject:newText]; + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"new"]]; } #pragma clang diagnostic pop - // The text should be updated since there's no active composition - XCTAssertEqualObjects( - mockTextField.attributedText.string, - @"replaced", - @"attributedText should be replaced when no IME composition is active"); + XCTAssertEqualObjects(mock.attributedText.string, @"new", + @"Text should be replaced when not composing"); } -#pragma mark - Fix 3: maxLength during IME composition +#pragma mark - Deferred defaultTextAttributes -- (void)testMaxLengthNotEnforcedDuringComposition +- (void)testDeferredAttributesPreservedDuringComposition { - // Verifies that textInputShouldChangeText does not block input during IME composition, - // even if maxLength would normally restrict it. - // - // Note: maxLength is a C++ prop (TextInputProps) and cannot be set via KVC in unit tests. - // With defaultSharedProps, maxLength = INT_MAX, so the maxLength branch is never entered. - // This test verifies that the markedTextRange guard allows text through unconditionally - // during composition. The post-composition truncation path in textInputDidChange (which - // handles grapheme-cluster-safe truncation) requires integration testing with real props. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; - - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; - - // Simulate active IME composition - mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - NSString *result = - [(id)componentView textInputShouldChangeText:@"additional" inRange:NSMakeRange(5, 0)]; + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} + forKey:@"_pendingDefaultTextAttributes"]; - // During composition, text should pass through unblocked - XCTAssertEqualObjects(result, @"additional", @"Text input should not be blocked during IME composition"); + mock.mockMarkedTextRange = createMockTextRange(mock); + [(id)cv textInputDidChange]; + + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNotNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); } -- (void)testMaxLengthEnforcedWhenNoComposition +- (void)testDeferredAttributesAppliedAfterComposition { - // Verifies that textInputShouldChangeText passes text through when maxLength is not constraining. - // See note in testMaxLengthNotEnforcedDuringComposition about prop limitations in unit tests. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} + forKey:@"_pendingDefaultTextAttributes"]; + + mock.mockMarkedTextRange = nil; + [(id)cv textInputDidChange]; + + XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); +} + +#pragma mark - Full lifecycle with deferred attributes - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; +- (void)testCompositionLifecycleWithDeferredAttributes +{ + // Composition starts → deferred attrs set mid-composition → composition ends → attrs applied + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Composition starts + mock.mockMarkedTextRange = createMockTextRange(mock); + mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ul]; + [delegate textInputDidChange]; + + // Mid-composition: deferred attribute update arrives + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont boldSystemFontOfSize:20]} + forKey:@"_pendingDefaultTextAttributes"]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ul]; + [delegate textInputDidChange]; + + // Still composing — deferred must be preserved + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; + [delegate textInputDidChange]; + + // Deferred attrs must now be applied + XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); + XCTAssertEqualObjects(mock.attributedText.string, @"한"); +} - // No active composition - mockTextField.mockMarkedTextRange = nil; +#pragma mark - maxLength + +- (void)testMaxLengthBypassedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + mock.mockMarkedTextRange = createMockTextRange(mock); NSString *result = - [(id)componentView textInputShouldChangeText:@"extra" inRange:NSMakeRange(5, 0)]; + [(id)cv textInputShouldChangeText:@"한" inRange:NSMakeRange(5, 0)]; + XCTAssertEqualObjects(result, @"한"); +} + +- (void)testMaxLengthPassthroughWhenNotComposing +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + mock.mockMarkedTextRange = nil; - XCTAssertEqualObjects(result, @"extra", @"Text should pass through when maxLength is not constraining"); + NSString *result = + [(id)cv textInputShouldChangeText:@"6" inRange:NSMakeRange(5, 0)]; + XCTAssertEqualObjects(result, @"6"); } -#pragma mark - Fix 4: multiline selection change bare text comparison +#pragma mark - Multiline bare text comparison -- (void)testMultilineSelectionChangeUsesBarTextComparison +- (void)testMultilineBareTextComparisonPreventsFalsePositive { - // This test verifies that textInputDidChangeSelection uses string comparison - // (not NSAttributedString isEqual:) to avoid false positives during IME composition. - // - // When IME composition is active, the attributed string has system-added underline - // attributes that cause isEqual: to fail even when the bare text is identical. - // Using isEqualToString: on the bare text avoids triggering unnecessary - // textInputDidChange calls. - - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; - - // Set up attributed text with system-style attributes (simulating IME underline) - NSMutableAttributedString *textWithSystemAttrs = + RCTMockTextView *mock; + RCTTextInputComponentView *cv = [self createMultiLineWithMock:&mock]; + + mock.attributedText = [[NSMutableAttributedString alloc] initWithString:@"test" attributes:@{ NSFontAttributeName : [UIFont systemFontOfSize:14], NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), }]; - mockTextView.attributedText = textWithSystemAttrs; - [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; - mockTextView.textInputDelegate = (id)componentView; - // Set _lastStringStateWasUpdatedWith to same text but different attributes - NSAttributedString *lastString = + NSAttributedString *withoutUL = [[NSAttributedString alloc] initWithString:@"test" attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; - [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; - - // The bare text is the same ("test" == "test"), so even though the attributed - // strings differ (due to NSUnderlineStyleAttributeName), the comparison - // should return YES (equal) and NOT trigger an extra textInputDidChange. - XCTAssertTrue( - [lastString.string isEqualToString:mockTextView.attributedText.string], - @"Bare text comparison should show strings are equal"); - XCTAssertFalse( - [lastString isEqual:mockTextView.attributedText], - @"Full attributed string comparison should show strings are NOT equal (different attributes)"); + [cv setValue:withoutUL forKey:@"_lastStringStateWasUpdatedWith"]; + + // Bare strings equal — new comparison returns YES (no false positive) + XCTAssertTrue([withoutUL.string isEqualToString:mock.attributedText.string]); + // Full attributed strings differ — old comparison would return NO + XCTAssertFalse([withoutUL isEqual:mock.attributedText]); } -- (void)testMultilineSelectionChangeNoExtraUpdateDuringComposition +#pragma mark - JS-driven update blocked during composition + +- (void)testJSDrivenTextUpdateBlockedDuringComposition { - // Verifies that during IME composition, the attributed string underline - // attributes added by the system do not cause a spurious textInputDidChange call - // in multiline mode. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; - - mockTextView.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; - [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; - mockTextView.textInputDelegate = (id)componentView; - - // Simulate active IME composition - mockTextView.mockMarkedTextRange = createMockTextRangeForTextView(mockTextView); - - // Set _lastStringStateWasUpdatedWith with same bare text - NSAttributedString *lastString = [[NSAttributedString alloc] initWithString:@"composing"]; - [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; - - // Since the bare text matches, textInputDidChangeSelection should NOT trigger textInputDidChange - // This is verified by the fact that _lastStringStateWasUpdatedWith.string equals attributedText.string - XCTAssertTrue( - [lastString.string isEqualToString:mockTextView.attributedText.string], - @"Bare text should match, preventing unnecessary textInputDidChange call"); + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing" + attributes:@{ + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }]; + mock.mockMarkedTextRange = createMockTextRange(mock); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"js-value"]]; + } +#pragma clang diagnostic pop + + XCTAssertEqualObjects(mock.attributedText.string, @"composing", + @"JS-driven update must be blocked during composition"); } @end diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm index 306427115034..de07f1f0722b 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm +++ b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm @@ -12,7 +12,7 @@ #import #import -#import "RCTTextInputComponentView.h" +#import /** * Mock UITextField subclass that allows overriding markedTextRange for testing @@ -78,247 +78,455 @@ @interface RCTTextInputComponentViewIMETests : XCTestCase @implementation RCTTextInputComponentViewIMETests -#pragma mark - Fix 1: updateEventEmitter defaultTextAttributes deferral +#pragma mark - Helpers -- (void)testDefaultTextAttributesSkippedDuringMarkedText +- (RCTTextInputComponentView *)createSingleLineWithMock:(RCTMockTextField **)outMock { - // Verifies that pending defaultTextAttributes are NOT applied while composition is active. - // textInputDidChange should only apply pending attributes after markedTextRange becomes nil. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mock = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + [cv setValue:mock forKey:@"_backedTextInputView"]; + mock.textInputDelegate = (id)cv; + if (outMock) { + *outMock = mock; + } + return cv; +} - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; +- (RCTTextInputComponentView *)createMultiLineWithMock:(RCTMockTextView **)outMock +{ + RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; + RCTMockTextView *mock = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; + [cv setValue:mock forKey:@"_backedTextInputView"]; + mock.textInputDelegate = (id)cv; + if (outMock) { + *outMock = mock; + } + return cv; +} - // Simulate deferred attributes (as if updateEventEmitter was called during composition) - NSDictionary *pendingAttributes = - @{NSFontAttributeName : [UIFont systemFontOfSize:18]}; - [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; +#pragma mark - Korean IME lifecycle - // Composition IS active - mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); +- (void)testKoreanCompositionLifecycle +{ + // Simulates the full UIKit delegate call sequence for typing "한": + // ㅎ (composing) → 하 (composing) → 한 (composing) → 한 (committed) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ulAttrs = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Step 1: ㅎ — composition starts + mock.mockMarkedTextRange = nil; + [delegate textInputShouldChangeText:@"ㅎ" inRange:NSMakeRange(0, 0)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ulAttrs]; + mock.mockMarkedTextRange = createMockTextRange(mock); + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"ㅎ"); + + // Step 2: 하 — vowel added + [delegate textInputShouldChangeText:@"하" inRange:NSMakeRange(0, 1)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"하" attributes:ulAttrs]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"하"); + + // Step 3: 한 — final consonant added + [delegate textInputShouldChangeText:@"한" inRange:NSMakeRange(0, 1)]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ulAttrs]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"한"); + + // Step 4: commit — markedTextRange cleared, underline removed + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"한"); +} - // Call textInputDidChange — pending attributes should NOT be applied yet - [(id)componentView textInputDidChange]; +#pragma mark - Japanese romaji lifecycle + +- (void)testJapaneseRomajiCompositionLifecycle +{ + // k → か → かn → かん → 漢 (candidate) → commit + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"k", @"か", @"かn", @"かん" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } + + // Candidate selection + mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"漢"); +} + +#pragma mark - Chinese Pinyin lifecycle + +- (void)testChinesePinyinCompositionLifecycle +{ + // z → zh → zhong → 中 (candidate) → commit + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"z", @"zh", @"zho", @"zhon", @"zhong" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } - // Verify pending was preserved (not applied) because composition is still active - BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; - XCTAssertTrue(needsUpdate, @"_needsUpdateDefaultTextAttributes should remain YES during composition"); + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; + [delegate textInputDidChange]; - id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; - XCTAssertNotNil(pendingAfter, @"_pendingDefaultTextAttributes should NOT be cleared during composition"); + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"中"); } -- (void)testPendingDefaultTextAttributesAppliedAfterCompositionEnds +#pragma mark - Korean multi-syllable lifecycle + +- (void)testKoreanMultiSyllableCompositionLifecycle { - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + // 감사 — two syllables: ㄱ→가→감 (commit) → ㅅ→사 (commit) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // First syllable: 감 + mock.mockMarkedTextRange = createMockTextRange(mock); + for (NSString *step in @[ @"ㄱ", @"가", @"감" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } + + // ㅅ is typed — 감 commits, new syllable starts: 감ㅅ + // System splits: "감" committed + "ㅅ" composing + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감ㅅ" attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, @"감ㅅ"); - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; + // ㅏ added: 감사 + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사" attributes:ul]; + [delegate textInputDidChange]; - // Set pending attributes (simulating what updateEventEmitter would do) - NSDictionary *pendingAttributes = - @{NSFontAttributeName : [UIFont systemFontOfSize:16]}; - [componentView setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [componentView setValue:pendingAttributes forKey:@"_pendingDefaultTextAttributes"]; + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"감사"); +} - // Composition is NOT active (markedTextRange is nil) - mockTextField.mockMarkedTextRange = nil; +#pragma mark - Japanese candidate re-selection lifecycle - // Call textInputDidChange — should apply pending attributes - [(id)componentView textInputDidChange]; +- (void)testJapaneseCandidateReselectionLifecycle +{ + // はし → 橋 → 箸 → 端 → 箸 (user scrolls candidates and confirms) + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Romaji input + for (NSString *step in @[ @"h", @"は", @"はs", @"はし" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } + + // Candidate selection — user scrolls through candidates + for (NSString *candidate in @[ @"橋", @"箸", @"端", @"箸" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:candidate attributes:ul]; + [delegate textInputDidChange]; + // Text should reflect the current candidate, not be replaced by JS + XCTAssertEqualObjects(mock.attributedText.string, candidate); + } - // After textInputDidChange with no markedText, pending should be cleared - BOOL needsUpdate = [[componentView valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]; - XCTAssertFalse(needsUpdate, @"_needsUpdateDefaultTextAttributes should be cleared after applying"); + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"箸"]; + [delegate textInputDidChange]; - id pendingAfter = [componentView valueForKey:@"_pendingDefaultTextAttributes"]; - XCTAssertNil(pendingAfter, @"_pendingDefaultTextAttributes should be nil after applying"); + XCTAssertEqualObjects(mock.attributedText.string, @"箸"); } -#pragma mark - Fix 2: _setAttributedString guard during composition +#pragma mark - Chinese Zhuyin (Bopomofo) lifecycle -- (void)testSetAttributedStringSkippedDuringMarkedText +- (void)testChineseZhuyinCompositionLifecycle { - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + // Zhuyin (Bopomofo) IME used in Taiwan: ㄓㄨㄥ → 中 + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + mock.mockMarkedTextRange = createMockTextRange(mock); + + for (NSString *step in @[ @"ㄓ", @"ㄓㄨ", @"ㄓㄨㄥ" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + XCTAssertEqualObjects(mock.attributedText.string, step); + } + + // Candidate selected + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"中"); +} + +#pragma mark - Mixed Latin + CJK lifecycle - NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; - mockTextField.attributedText = originalText; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; +- (void)testMixedLatinAndCJKComposition +{ + // User types "Hello" in Latin, then switches to Japanese IME and types "世界" + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Latin input (no composition) + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; + [delegate textInputDidChange]; + + // Japanese composition starts after "Hello" + mock.mockMarkedTextRange = createMockTextRange(mock); + for (NSString *step in @[ @"Hellos", @"Helloせ", @"Helloせk", @"Helloせか", @"Helloせかi", @"Helloせかい" ]) { + mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; + [delegate textInputDidChange]; + } - // Simulate active IME composition - mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + // Candidate selected + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界" attributes:ul]; + [delegate textInputDidChange]; + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界"]; + [delegate textInputDidChange]; + + XCTAssertEqualObjects(mock.attributedText.string, @"Hello世界"); +} + +#pragma mark - _setAttributedString guard + +- (void)testSetAttributedStringBlockedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; + mock.mockMarkedTextRange = createMockTextRange(mock); - // Try to set a different attributed string via the private method - NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; - // Use performSelector to call private method _setAttributedString: #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL selector = NSSelectorFromString(@"_setAttributedString:"); - if ([componentView respondsToSelector:selector]) { - [componentView performSelector:selector withObject:newText]; + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"replaced"]]; } #pragma clang diagnostic pop - // The text should remain unchanged because markedTextRange is active - XCTAssertEqualObjects( - mockTextField.attributedText.string, - @"original", - @"attributedText should not be replaced during IME composition"); + XCTAssertEqualObjects(mock.attributedText.string, @"composing", + @"Text must not be replaced during composition"); } -- (void)testSetAttributedStringAppliedWhenNoMarkedText +- (void)testSetAttributedStringAllowedAfterComposition { - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - NSAttributedString *originalText = [[NSAttributedString alloc] initWithString:@"original"]; - mockTextField.attributedText = originalText; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"old"]; + mock.mockMarkedTextRange = nil; - // No IME composition - mockTextField.mockMarkedTextRange = nil; - - // Set a different attributed string - NSAttributedString *newText = [[NSAttributedString alloc] initWithString:@"replaced"]; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL selector = NSSelectorFromString(@"_setAttributedString:"); - if ([componentView respondsToSelector:selector]) { - [componentView performSelector:selector withObject:newText]; + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"new"]]; } #pragma clang diagnostic pop - // The text should be updated since there's no active composition - XCTAssertEqualObjects( - mockTextField.attributedText.string, - @"replaced", - @"attributedText should be replaced when no IME composition is active"); + XCTAssertEqualObjects(mock.attributedText.string, @"new", + @"Text should be replaced when not composing"); } -#pragma mark - Fix 3: maxLength during IME composition +#pragma mark - Deferred defaultTextAttributes -- (void)testMaxLengthNotEnforcedDuringComposition +- (void)testDeferredAttributesPreservedDuringComposition { - // Verifies that textInputShouldChangeText does not block input during IME composition, - // even if maxLength would normally restrict it. - // - // Note: maxLength is a C++ prop (TextInputProps) and cannot be set via KVC in unit tests. - // With defaultSharedProps, maxLength = INT_MAX, so the maxLength branch is never entered. - // This test verifies that the markedTextRange guard allows text through unconditionally - // during composition. The post-composition truncation path in textInputDidChange (which - // handles grapheme-cluster-safe truncation) requires integration testing with real props. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; - - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; - - // Simulate active IME composition - mockTextField.mockMarkedTextRange = createMockTextRange(mockTextField); + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - NSString *result = - [(id)componentView textInputShouldChangeText:@"additional" inRange:NSMakeRange(5, 0)]; + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} + forKey:@"_pendingDefaultTextAttributes"]; - // During composition, text should pass through unblocked - XCTAssertEqualObjects(result, @"additional", @"Text input should not be blocked during IME composition"); + mock.mockMarkedTextRange = createMockTextRange(mock); + [(id)cv textInputDidChange]; + + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNotNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); } -- (void)testMaxLengthEnforcedWhenNoComposition +- (void)testDeferredAttributesAppliedAfterComposition { - // Verifies that textInputShouldChangeText passes text through when maxLength is not constraining. - // See note in testMaxLengthNotEnforcedDuringComposition about prop limitations in unit tests. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mockTextField = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; + + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} + forKey:@"_pendingDefaultTextAttributes"]; + + mock.mockMarkedTextRange = nil; + [(id)cv textInputDidChange]; + + XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); +} + +#pragma mark - Full lifecycle with deferred attributes - mockTextField.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; - [componentView setValue:mockTextField forKey:@"_backedTextInputView"]; - mockTextField.textInputDelegate = (id)componentView; +- (void)testCompositionLifecycleWithDeferredAttributes +{ + // Composition starts → deferred attrs set mid-composition → composition ends → attrs applied + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; + + // Composition starts + mock.mockMarkedTextRange = createMockTextRange(mock); + mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ul]; + [delegate textInputDidChange]; + + // Mid-composition: deferred attribute update arrives + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:@{NSFontAttributeName : [UIFont boldSystemFontOfSize:20]} + forKey:@"_pendingDefaultTextAttributes"]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ul]; + [delegate textInputDidChange]; + + // Still composing — deferred must be preserved + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + + // Commit + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; + [delegate textInputDidChange]; + + // Deferred attrs must now be applied + XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); + XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); + XCTAssertEqualObjects(mock.attributedText.string, @"한"); +} - // No active composition - mockTextField.mockMarkedTextRange = nil; +#pragma mark - maxLength + +- (void)testMaxLengthBypassedDuringComposition +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + mock.mockMarkedTextRange = createMockTextRange(mock); NSString *result = - [(id)componentView textInputShouldChangeText:@"extra" inRange:NSMakeRange(5, 0)]; + [(id)cv textInputShouldChangeText:@"한" inRange:NSMakeRange(5, 0)]; + XCTAssertEqualObjects(result, @"한"); +} + +- (void)testMaxLengthPassthroughWhenNotComposing +{ + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; + mock.mockMarkedTextRange = nil; - XCTAssertEqualObjects(result, @"extra", @"Text should pass through when maxLength is not constraining"); + NSString *result = + [(id)cv textInputShouldChangeText:@"6" inRange:NSMakeRange(5, 0)]; + XCTAssertEqualObjects(result, @"6"); } -#pragma mark - Fix 4: multiline selection change bare text comparison +#pragma mark - Multiline bare text comparison -- (void)testMultilineSelectionChangeUsesBarTextComparison +- (void)testMultilineBareTextComparisonPreventsFalsePositive { - // This test verifies that textInputDidChangeSelection uses string comparison - // (not NSAttributedString isEqual:) to avoid false positives during IME composition. - // - // When IME composition is active, the attributed string has system-added underline - // attributes that cause isEqual: to fail even when the bare text is identical. - // Using isEqualToString: on the bare text avoids triggering unnecessary - // textInputDidChange calls. - - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; - - // Set up attributed text with system-style attributes (simulating IME underline) - NSMutableAttributedString *textWithSystemAttrs = + RCTMockTextView *mock; + RCTTextInputComponentView *cv = [self createMultiLineWithMock:&mock]; + + mock.attributedText = [[NSMutableAttributedString alloc] initWithString:@"test" attributes:@{ NSFontAttributeName : [UIFont systemFontOfSize:14], NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), }]; - mockTextView.attributedText = textWithSystemAttrs; - [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; - mockTextView.textInputDelegate = (id)componentView; - // Set _lastStringStateWasUpdatedWith to same text but different attributes - NSAttributedString *lastString = + NSAttributedString *withoutUL = [[NSAttributedString alloc] initWithString:@"test" attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; - [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; - - // The bare text is the same ("test" == "test"), so even though the attributed - // strings differ (due to NSUnderlineStyleAttributeName), the comparison - // should return YES (equal) and NOT trigger an extra textInputDidChange. - XCTAssertTrue( - [lastString.string isEqualToString:mockTextView.attributedText.string], - @"Bare text comparison should show strings are equal"); - XCTAssertFalse( - [lastString isEqual:mockTextView.attributedText], - @"Full attributed string comparison should show strings are NOT equal (different attributes)"); + [cv setValue:withoutUL forKey:@"_lastStringStateWasUpdatedWith"]; + + // Bare strings equal — new comparison returns YES (no false positive) + XCTAssertTrue([withoutUL.string isEqualToString:mock.attributedText.string]); + // Full attributed strings differ — old comparison would return NO + XCTAssertFalse([withoutUL isEqual:mock.attributedText]); } -- (void)testMultilineSelectionChangeNoExtraUpdateDuringComposition +#pragma mark - JS-driven update blocked during composition + +- (void)testJSDrivenTextUpdateBlockedDuringComposition { - // Verifies that during IME composition, the attributed string underline - // attributes added by the system do not cause a spurious textInputDidChange call - // in multiline mode. - RCTTextInputComponentView *componentView = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextView *mockTextView = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; - - mockTextView.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; - [componentView setValue:mockTextView forKey:@"_backedTextInputView"]; - mockTextView.textInputDelegate = (id)componentView; - - // Simulate active IME composition - mockTextView.mockMarkedTextRange = createMockTextRangeForTextView(mockTextView); - - // Set _lastStringStateWasUpdatedWith with same bare text - NSAttributedString *lastString = [[NSAttributedString alloc] initWithString:@"composing"]; - [componentView setValue:lastString forKey:@"_lastStringStateWasUpdatedWith"]; - - // Since the bare text matches, textInputDidChangeSelection should NOT trigger textInputDidChange - // This is verified by the fact that _lastStringStateWasUpdatedWith.string equals attributedText.string - XCTAssertTrue( - [lastString.string isEqualToString:mockTextView.attributedText.string], - @"Bare text should match, preventing unnecessary textInputDidChange call"); + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing" + attributes:@{ + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }]; + mock.mockMarkedTextRange = createMockTextRange(mock); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel + withObject:[[NSAttributedString alloc] initWithString:@"js-value"]]; + } +#pragma clang diagnostic pop + + XCTAssertEqualObjects(mock.attributedText.string, @"composing", + @"JS-driven update must be blocked during composition"); } @end From 5745dee7fa99cc4881282cdb4371585884d4a049 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 15:07:56 +0900 Subject: [PATCH 08/12] Add underline preservation tests, fix Prettier lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3 tests verifying composition underline (NSUnderlineStyleAttributeName) is preserved during state round-trips, deferred attribute updates, and correctly removed after commit - Fix Prettier formatting in TextInput-ime-test.js (CI lint failure) - Remove Maestro textinput-ime.yml (inputText bypasses IME) 19 native tests, 18 JS tests — all passing. --- .../TextInput/__tests__/TextInput-ime-test.js | 4 +- .../RCTTextInputComponentViewIMETests.mm | 102 ++++++++++++++++++ .../RCTTextInputComponentViewIMETests.mm | 102 ++++++++++++++++++ 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js index b7ca98989b97..78789da4aa27 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js @@ -64,9 +64,7 @@ describe('TextInput IME composition behavior', () => { }); expect(onChangeText).toHaveBeenCalledTimes(4); - expect(onChangeText.mock.calls.map(c => c[0])).toEqual( - compositionSteps, - ); + expect(onChangeText.mock.calls.map(c => c[0])).toEqual(compositionSteps); expect(currentText).toBe('한글'); }); diff --git a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm index a4d665fafc19..39c94ed69b60 100644 --- a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm +++ b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm @@ -334,6 +334,108 @@ - (void)testMixedLatinAndCJKComposition XCTAssertEqualObjects(mock.attributedText.string, @"Hello世界"); } +#pragma mark - Composition underline preservation + +- (void)testCompositionUnderlinePreservedDuringStateRoundTrip +{ + // The core bug: during IME composition, the system adds NSUnderlineStyleAttributeName + // to show the composition underline. If _setAttributedString is called (e.g., from a + // Fabric state round-trip), it would replace the attributed text with one that has NO + // underline, destroying the visual composition indicator. Our guard must prevent this. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + // Set up text WITH underline (simulating active IME composition) + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Attempt to replace with text WITHOUT underline (simulating state round-trip) + NSAttributedString *withoutUnderline = + [[NSAttributedString alloc] initWithString:@"한" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel withObject:withoutUnderline]; + } +#pragma clang diagnostic pop + + // The underline attribute must still be present (guard blocked the replacement) + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNotNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Composition underline must be preserved — _setAttributedString should be blocked during composition"); +} + +- (void)testCompositionUnderlinePreservedDuringDeferredAttributeUpdate +{ + // When updateEventEmitter defers defaultTextAttributes during composition, + // the underline on the current text must not be disturbed. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"か" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Simulate deferred attribute update (from updateEventEmitter during composition) + NSDictionary *newAttrs = @{NSFontAttributeName : [UIFont boldSystemFontOfSize:16]}; + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:newAttrs forKey:@"_pendingDefaultTextAttributes"]; + + // textInputDidChange fires mid-composition — deferred attrs must NOT be applied + [delegate textInputDidChange]; + + // Underline must still be on the text + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNotNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Composition underline must survive deferred attribute update during composition"); + + // Deferred flag must still be pending + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); +} + +- (void)testUnderlineRemovedAfterCompositionCommit +{ + // After composition commits, the system removes the underline. + // Our code should allow normal attribute updates at this point. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + // Mid-composition: underline present + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + [delegate textInputDidChange]; + + // Commit: system removes underline and clears markedTextRange + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + [delegate textInputDidChange]; + + // After commit, underline should be gone (system removed it) + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Underline should be removed after composition commits"); +} + #pragma mark - _setAttributedString guard - (void)testSetAttributedStringBlockedDuringComposition diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm index de07f1f0722b..49556a37650e 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm +++ b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm @@ -334,6 +334,108 @@ - (void)testMixedLatinAndCJKComposition XCTAssertEqualObjects(mock.attributedText.string, @"Hello世界"); } +#pragma mark - Composition underline preservation + +- (void)testCompositionUnderlinePreservedDuringStateRoundTrip +{ + // The core bug: during IME composition, the system adds NSUnderlineStyleAttributeName + // to show the composition underline. If _setAttributedString is called (e.g., from a + // Fabric state round-trip), it would replace the attributed text with one that has NO + // underline, destroying the visual composition indicator. Our guard must prevent this. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + // Set up text WITH underline (simulating active IME composition) + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Attempt to replace with text WITHOUT underline (simulating state round-trip) + NSAttributedString *withoutUnderline = + [[NSAttributedString alloc] initWithString:@"한" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = NSSelectorFromString(@"_setAttributedString:"); + if ([cv respondsToSelector:sel]) { + [cv performSelector:sel withObject:withoutUnderline]; + } +#pragma clang diagnostic pop + + // The underline attribute must still be present (guard blocked the replacement) + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNotNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Composition underline must be preserved — _setAttributedString should be blocked during composition"); +} + +- (void)testCompositionUnderlinePreservedDuringDeferredAttributeUpdate +{ + // When updateEventEmitter defers defaultTextAttributes during composition, + // the underline on the current text must not be disturbed. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"か" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + + // Simulate deferred attribute update (from updateEventEmitter during composition) + NSDictionary *newAttrs = @{NSFontAttributeName : [UIFont boldSystemFontOfSize:16]}; + [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; + [cv setValue:newAttrs forKey:@"_pendingDefaultTextAttributes"]; + + // textInputDidChange fires mid-composition — deferred attrs must NOT be applied + [delegate textInputDidChange]; + + // Underline must still be on the text + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNotNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Composition underline must survive deferred attribute update during composition"); + + // Deferred flag must still be pending + XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); +} + +- (void)testUnderlineRemovedAfterCompositionCommit +{ + // After composition commits, the system removes the underline. + // Our code should allow normal attribute updates at this point. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + id delegate = (id)cv; + + // Mid-composition: underline present + NSDictionary *withUnderline = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; + mock.mockMarkedTextRange = createMockTextRange(mock); + [delegate textInputDidChange]; + + // Commit: system removes underline and clears markedTextRange + mock.mockMarkedTextRange = nil; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; + [delegate textInputDidChange]; + + // After commit, underline should be gone (system removed it) + NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; + XCTAssertNil( + resultAttrs[NSUnderlineStyleAttributeName], + @"Underline should be removed after composition commits"); +} + #pragma mark - _setAttributedString guard - (void)testSetAttributedStringBlockedDuringComposition From 5f7d740ef7f88a1ae2df818820558b677db85c79 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 16:41:58 +0900 Subject: [PATCH 09/12] Defer _updateState and selection updates during IME composition - Skip _updateState in textInputDidChange during composition to prevent Fabric round-trips that interfere with UIKit's marked text rendering - Guard setTextAndSelection selection updates during composition to prevent setSelectedTextRange: from clearing markedTextRange - Remove debug logging, fix Flow type errors in JS test file --- .../TextInput/__tests__/TextInput-ime-test.js | 54 ++++++++++++------- .../TextInput/RCTTextInputComponentView.mm | 36 ++++++++----- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js index 78789da4aa27..08b16c9f1139 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js @@ -48,11 +48,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // Simulate Korean composition steps @@ -88,11 +89,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // Romaji "kanji" → hiragana → kanji conversion @@ -135,11 +137,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); const compositionSteps = [ @@ -181,11 +184,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); const compositionSteps = [ @@ -229,11 +233,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // First character: "zhong" → 中 @@ -277,11 +282,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // Wubi input for 王 (wang/king) @@ -315,11 +321,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); const compositionSteps = ['ㄓ', 'ㄓㄨ', 'ㄓㄨㄥ', '中']; @@ -353,11 +360,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); const compositionSteps = [ @@ -403,11 +411,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); ReactTestRenderer.act(() => { @@ -446,11 +455,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // During composition, native sends text that may temporarily exceed maxLength. @@ -479,13 +489,14 @@ describe('TextInput IME composition behavior', () => { const onChange = jest.fn(); const onChangeText = jest.fn(); - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create( , ); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); ReactTestRenderer.act(() => { @@ -521,11 +532,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // Composition on second line @@ -564,11 +576,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // Latin portion typed directly @@ -614,11 +627,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // Chinese Pinyin: "ni" → 你 @@ -662,11 +676,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // "React " typed in Latin @@ -720,11 +735,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // First word: watashi → 私 @@ -789,11 +805,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // "wo" → 我 @@ -852,11 +869,12 @@ describe('TextInput IME composition behavior', () => { ); } - let renderer: $FlowFixMe; + let renderer; ReactTestRenderer.act(() => { renderer = ReactTestRenderer.create(); }); + // $FlowFixMe[incompatible-use] const input = renderer.root.findByType(TextInput); // Composition intermediate 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 23dc4cefbf18..3a2b73433b77 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -578,7 +578,12 @@ - (void)textInputDidChange } } - [self _updateState]; + // During IME composition, defer _updateState to avoid Fabric round-trips + // that can interfere with UIKit's marked text rendering. + // State will be pushed when composition commits (markedTextRange becomes nil). + if (!_backedTextInputView.markedTextRange) { + [self _updateState]; + } if (_eventEmitter) { const auto &textInputEventEmitter = static_cast(*_eventEmitter); @@ -670,18 +675,23 @@ - (void)setTextAndSelection:(NSInteger)eventCount // JS can re-assert its controlled value through a new setTextAndSelection call. } - UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument - offset:start]; - UITextPosition *endPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument - offset:end]; - - if (startPosition && endPosition) { - UITextRange *range = [_backedTextInputView textRangeFromPosition:startPosition toPosition:endPosition]; - [_backedTextInputView setSelectedTextRange:range notifyDelegate:NO]; - // ensure we scroll to the selected position - NSInteger offsetEnd = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument - toPosition:range.end]; - [_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetEnd, 0)]; + // During IME composition, skip selection updates from JS. + // Calling setSelectedTextRange: while markedTextRange is active causes UIKit + // to clear the marked text range, destroying the composition underline. + if (!_backedTextInputView.markedTextRange) { + UITextPosition *startPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:start]; + UITextPosition *endPosition = [_backedTextInputView positionFromPosition:_backedTextInputView.beginningOfDocument + offset:end]; + + if (startPosition && endPosition) { + UITextRange *range = [_backedTextInputView textRangeFromPosition:startPosition toPosition:endPosition]; + [_backedTextInputView setSelectedTextRange:range notifyDelegate:NO]; + // ensure we scroll to the selected position + NSInteger offsetEnd = [_backedTextInputView offsetFromPosition:_backedTextInputView.beginningOfDocument + toPosition:range.end]; + [_backedTextInputView scrollRangeToVisible:NSMakeRange(offsetEnd, 0)]; + } } _comingFromJS = NO; } From 97e317dc575318ca1af6873797b832147fa48301 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 19:22:30 +0900 Subject: [PATCH 10/12] Strip no-op NSShadow and NSBackgroundColor from typingAttributes to fix IME composition underline UIKit uses typingAttributes/defaultTextAttributes as the base for IME composition marked text rendering. When these contain no-op NSShadow (empty shadow with zero offset/blur) or transparent NSBackgroundColor, UIKit fails to render the composition underline on marked text. These no-op values are added by UIKit as defaults and propagated through React Native's defaultTextAttributes flow. This commit strips them (along with the React-internal EventEmitter attribute) before passing to UIKit, while preserving user-specified shadow/background values. Changes: - RCTUITextView.setDefaultTextAttributes: strip from typingAttributes - RCTUITextField.setDefaultTextAttributes: strip before [super set...] - _updateTypingAttributes: strip when reading from attributed text - Add native tests for stripping behavior and value preservation --- .../Text/TextInput/Multiline/RCTUITextView.mm | 14 ++- .../TextInput/Singleline/RCTUITextField.mm | 17 +++- .../TextInput/RCTTextInputComponentView.mm | 16 +++- .../RCTTextInputComponentViewIMETests.mm | 85 +++++++++++++++++++ .../RCTTextInputComponentViewIMETests.mm | 85 +++++++++++++++++++ 5 files changed, 213 insertions(+), 4 deletions(-) diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index cbda3771e97c..057c622f18f3 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -135,7 +135,19 @@ - (void)setDefaultTextAttributes:(NSDictionary *)defa } _defaultTextAttributes = defaultTextAttributes; - self.typingAttributes = defaultTextAttributes; + // Strip attributes that interfere with UIKit's IME composition underline rendering. + // Only remove no-op defaults; preserve user-specified values. + NSMutableDictionary *typingAttrs = [defaultTextAttributes mutableCopy]; + [typingAttrs removeObjectForKey:@"EventEmitter"]; + NSShadow *shadow = typingAttrs[NSShadowAttributeName]; + if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { + [typingAttrs removeObjectForKey:NSShadowAttributeName]; + } + UIColor *bgColor = typingAttrs[NSBackgroundColorAttributeName]; + if (bgColor && CGColorGetAlpha(bgColor.CGColor) == 0) { + [typingAttrs removeObjectForKey:NSBackgroundColorAttributeName]; + } + self.typingAttributes = typingAttrs; [self _updatePlaceholder]; } diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index 052c003476d1..f457b8b71deb 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -93,7 +93,22 @@ - (void)setDefaultTextAttributes:(NSDictionary *)defa } _defaultTextAttributes = defaultTextAttributes; - [super setDefaultTextAttributes:defaultTextAttributes]; + // Strip attributes that interfere with UIKit's IME composition underline rendering. + // NSShadow and NSBackgroundColor prevent UIKit from drawing the composition underline + // on marked text, but only remove them when they are no-op defaults (empty shadow, + // clear background) — preserve user-specified values. + // EventEmitter is a React-internal attribute (NSData wrapping C++ weak_ptr). + NSMutableDictionary *uikitAttrs = [defaultTextAttributes mutableCopy]; + [uikitAttrs removeObjectForKey:@"EventEmitter"]; + NSShadow *shadow = uikitAttrs[NSShadowAttributeName]; + if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { + [uikitAttrs removeObjectForKey:NSShadowAttributeName]; + } + UIColor *bgColor = uikitAttrs[NSBackgroundColorAttributeName]; + if (bgColor && CGColorGetAlpha(bgColor.CGColor) == 0) { + [uikitAttrs removeObjectForKey:NSBackgroundColorAttributeName]; + } + [super setDefaultTextAttributes:uikitAttrs]; [self _updatePlaceholder]; } 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 3a2b73433b77..e893cb825ad6 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -911,8 +911,20 @@ - (void)_updateTypingAttributes toPosition:_backedTextInputView.selectedTextRange.start]; NSUInteger samplePoint = offsetStart == 0 ? 0 : offsetStart - 1; - _backedTextInputView.typingAttributes = [_backedTextInputView.attributedText attributesAtIndex:samplePoint - effectiveRange:NULL]; + NSMutableDictionary *attrs = [[_backedTextInputView.attributedText attributesAtIndex:samplePoint + effectiveRange:NULL] mutableCopy]; + // Strip attributes that interfere with UIKit's IME composition underline rendering. + // Only remove no-op defaults; preserve user-specified values. + [attrs removeObjectForKey:RCTAttributedStringEventEmitterKey]; + NSShadow *shadow = attrs[NSShadowAttributeName]; + if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { + [attrs removeObjectForKey:NSShadowAttributeName]; + } + UIColor *bgColor = attrs[NSBackgroundColorAttributeName]; + if (bgColor && CGColorGetAlpha(bgColor.CGColor) == 0) { + [attrs removeObjectForKey:NSBackgroundColorAttributeName]; + } + _backedTextInputView.typingAttributes = attrs; } } diff --git a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm index 39c94ed69b60..636a18e8392d 100644 --- a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm +++ b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm @@ -631,4 +631,89 @@ - (void)testJSDrivenTextUpdateBlockedDuringComposition @"JS-driven update must be blocked during composition"); } +#pragma mark - typingAttributes stripping for IME composition underline + +- (void)testTypingAttributesStrippedForMultiline +{ + // UITextView (multiline): setDefaultTextAttributes: must strip EventEmitter, + // no-op NSShadow, and transparent NSBackgroundColor from typingAttributes. + // These attributes prevent UIKit from rendering the IME composition underline. + RCTMockTextView *mock; + [self createMultiLineWithMock:&mock]; + + NSShadow *noopShadow = [NSShadow new]; // offset (0,0), blur 0 + NSDictionary *attrs = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSForegroundColorAttributeName : [UIColor blackColor], + @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], + NSShadowAttributeName : noopShadow, + NSBackgroundColorAttributeName : [UIColor clearColor], + }; + mock.defaultTextAttributes = attrs; + + XCTAssertNil(mock.typingAttributes[@"EventEmitter"], + @"EventEmitter must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], + @"No-op NSShadow must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"Transparent NSBackgroundColor must be stripped from typingAttributes"); + XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], + @"Standard attributes like font must be preserved"); + XCTAssertNotNil(mock.typingAttributes[NSForegroundColorAttributeName], + @"Standard attributes like foreground color must be preserved"); +} + +- (void)testRealShadowAndBackgroundPreservedInMultilineTypingAttributes +{ + // User-specified shadow and background color must NOT be stripped. + RCTMockTextView *mock; + [self createMultiLineWithMock:&mock]; + + NSShadow *realShadow = [NSShadow new]; + realShadow.shadowOffset = CGSizeMake(1, 1); + realShadow.shadowBlurRadius = 2; + NSDictionary *attrs = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSShadowAttributeName : realShadow, + NSBackgroundColorAttributeName : [UIColor yellowColor], + }; + mock.defaultTextAttributes = attrs; + + XCTAssertNotNil(mock.typingAttributes[NSShadowAttributeName], + @"User-specified NSShadow must be preserved"); + XCTAssertNotNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"User-specified NSBackgroundColor must be preserved"); +} + +- (void)testUpdateTypingAttributesStripsIMEBlockingAttrs +{ + // _updateTypingAttributes reads from attributed text (which has EventEmitter + // and no-op attrs from Fabric). These must be stripped so UIKit can render + // the composition underline on subsequent compositions. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + NSShadow *noopShadow = [NSShadow new]; + NSDictionary *attrsWithJunk = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], + NSShadowAttributeName : noopShadow, + NSBackgroundColorAttributeName : [UIColor clearColor], + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test" attributes:attrsWithJunk]; + mock.mockMarkedTextRange = nil; + mock.selectedTextRange = [mock textRangeFromPosition:mock.beginningOfDocument toPosition:mock.beginningOfDocument]; + + [(id)cv textInputDidChangeSelection]; + + XCTAssertNil(mock.typingAttributes[@"EventEmitter"], + @"EventEmitter must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], + @"No-op NSShadow must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"Transparent NSBackgroundColor must be stripped from typingAttributes"); + XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], + @"Font must be preserved in typingAttributes"); +} + @end diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm index 49556a37650e..96a4a4057fbc 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm +++ b/packages/rn-tester/RNTesterUnitTests/RCTTextInputComponentViewIMETests.mm @@ -631,4 +631,89 @@ - (void)testJSDrivenTextUpdateBlockedDuringComposition @"JS-driven update must be blocked during composition"); } +#pragma mark - typingAttributes stripping for IME composition underline + +- (void)testTypingAttributesStrippedForMultiline +{ + // UITextView (multiline): setDefaultTextAttributes: must strip EventEmitter, + // no-op NSShadow, and transparent NSBackgroundColor from typingAttributes. + // These attributes prevent UIKit from rendering the IME composition underline. + RCTMockTextView *mock; + [self createMultiLineWithMock:&mock]; + + NSShadow *noopShadow = [NSShadow new]; // offset (0,0), blur 0 + NSDictionary *attrs = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSForegroundColorAttributeName : [UIColor blackColor], + @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], + NSShadowAttributeName : noopShadow, + NSBackgroundColorAttributeName : [UIColor clearColor], + }; + mock.defaultTextAttributes = attrs; + + XCTAssertNil(mock.typingAttributes[@"EventEmitter"], + @"EventEmitter must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], + @"No-op NSShadow must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"Transparent NSBackgroundColor must be stripped from typingAttributes"); + XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], + @"Standard attributes like font must be preserved"); + XCTAssertNotNil(mock.typingAttributes[NSForegroundColorAttributeName], + @"Standard attributes like foreground color must be preserved"); +} + +- (void)testRealShadowAndBackgroundPreservedInMultilineTypingAttributes +{ + // User-specified shadow and background color must NOT be stripped. + RCTMockTextView *mock; + [self createMultiLineWithMock:&mock]; + + NSShadow *realShadow = [NSShadow new]; + realShadow.shadowOffset = CGSizeMake(1, 1); + realShadow.shadowBlurRadius = 2; + NSDictionary *attrs = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + NSShadowAttributeName : realShadow, + NSBackgroundColorAttributeName : [UIColor yellowColor], + }; + mock.defaultTextAttributes = attrs; + + XCTAssertNotNil(mock.typingAttributes[NSShadowAttributeName], + @"User-specified NSShadow must be preserved"); + XCTAssertNotNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"User-specified NSBackgroundColor must be preserved"); +} + +- (void)testUpdateTypingAttributesStripsIMEBlockingAttrs +{ + // _updateTypingAttributes reads from attributed text (which has EventEmitter + // and no-op attrs from Fabric). These must be stripped so UIKit can render + // the composition underline on subsequent compositions. + RCTMockTextField *mock; + RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; + + NSShadow *noopShadow = [NSShadow new]; + NSDictionary *attrsWithJunk = @{ + NSFontAttributeName : [UIFont systemFontOfSize:14], + @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], + NSShadowAttributeName : noopShadow, + NSBackgroundColorAttributeName : [UIColor clearColor], + }; + mock.attributedText = [[NSAttributedString alloc] initWithString:@"test" attributes:attrsWithJunk]; + mock.mockMarkedTextRange = nil; + mock.selectedTextRange = [mock textRangeFromPosition:mock.beginningOfDocument toPosition:mock.beginningOfDocument]; + + [(id)cv textInputDidChangeSelection]; + + XCTAssertNil(mock.typingAttributes[@"EventEmitter"], + @"EventEmitter must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], + @"No-op NSShadow must be stripped from typingAttributes"); + XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], + @"Transparent NSBackgroundColor must be stripped from typingAttributes"); + XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], + @"Font must be preserved in typingAttributes"); +} + @end From 24df8e1409afc7720dabdf9f1c815e3aab66374e Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Wed, 18 Mar 2026 19:36:34 +0900 Subject: [PATCH 11/12] Remove unused import to fix lint warning --- .../Components/TextInput/__tests__/TextInput-ime-test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js index 08b16c9f1139..fa8e3adc883d 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-ime-test.js @@ -10,7 +10,6 @@ const {enter} = require('../../../Utilities/ReactNativeTestTools'); const TextInput = require('../TextInput').default; -const {create} = require('@react-native/jest-preset/jest/renderer'); const React = require('react'); const {createRef, useState} = require('react'); const ReactTestRenderer = require('react-test-renderer'); From 1b69da3ffcdda147f33449551aabe8057d2ff325 Mon Sep 17 00:00:00 2001 From: Daewoon Kim Date: Thu, 19 Mar 2026 12:13:34 +0900 Subject: [PATCH 12/12] Fix updateEventEmitter clobbering deferred text styles and preserve EventEmitter key in traitCollectionDidChange - updateEventEmitter: use _pendingDefaultTextAttributes as base when it exists, so deferred text-style changes from updateProps are not lost - traitCollectionDidChange: preserve EventEmitter key in the non-composing branch (was already preserved in the composing branch) - RCTUITextView/RCTUITextField: replace @"EventEmitter" string literal with local constant kRCTEventEmitterAttributeKey for consistency - Remove duplicate test file from React/Tests/TextInput/ (canonical copy is in rn-tester/RNTesterUnitTests/) --- .../Text/TextInput/Multiline/RCTUITextView.mm | 6 +- .../TextInput/Singleline/RCTUITextField.mm | 6 +- .../TextInput/RCTTextInputComponentView.mm | 11 +- .../RCTTextInputComponentViewIMETests.mm | 719 ------------------ 4 files changed, 20 insertions(+), 722 deletions(-) delete mode 100644 packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm diff --git a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index 057c622f18f3..e9e870d142df 100644 --- a/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm +++ b/packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -13,6 +13,10 @@ #import #import +// Must match RCTAttributedStringEventEmitterKey in RCTAttributedTextUtils.h +// (cannot import directly — Libraries/Text must not depend on ReactCommon). +static NSString *const kRCTEventEmitterAttributeKey = @"EventEmitter"; + @implementation RCTUITextView { UILabel *_placeholderView; UITextView *_detachedTextView; @@ -138,7 +142,7 @@ - (void)setDefaultTextAttributes:(NSDictionary *)defa // Strip attributes that interfere with UIKit's IME composition underline rendering. // Only remove no-op defaults; preserve user-specified values. NSMutableDictionary *typingAttrs = [defaultTextAttributes mutableCopy]; - [typingAttrs removeObjectForKey:@"EventEmitter"]; + [typingAttrs removeObjectForKey:kRCTEventEmitterAttributeKey]; NSShadow *shadow = typingAttrs[NSShadowAttributeName]; if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { [typingAttrs removeObjectForKey:NSShadowAttributeName]; diff --git a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm index f457b8b71deb..9d97e6959074 100644 --- a/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm +++ b/packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm @@ -12,6 +12,10 @@ #import #import +// Must match RCTAttributedStringEventEmitterKey in RCTAttributedTextUtils.h +// (cannot import directly — Libraries/Text must not depend on ReactCommon). +static NSString *const kRCTEventEmitterAttributeKey = @"EventEmitter"; + @implementation RCTUITextField { RCTBackedTextFieldDelegateAdapter *_textInputDelegateAdapter; NSDictionary *_defaultTextAttributes; @@ -99,7 +103,7 @@ - (void)setDefaultTextAttributes:(NSDictionary *)defa // clear background) — preserve user-specified values. // EventEmitter is a React-internal attribute (NSData wrapping C++ weak_ptr). NSMutableDictionary *uikitAttrs = [defaultTextAttributes mutableCopy]; - [uikitAttrs removeObjectForKey:@"EventEmitter"]; + [uikitAttrs removeObjectForKey:kRCTEventEmitterAttributeKey]; NSShadow *shadow = uikitAttrs[NSShadowAttributeName]; if (shadow && CGSizeEqualToSize(shadow.shadowOffset, CGSizeZero) && shadow.shadowBlurRadius == 0) { [uikitAttrs removeObjectForKey:NSShadowAttributeName]; 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 e893cb825ad6..b34fd816012a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -117,8 +117,12 @@ - (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter { [super updateEventEmitter:eventEmitter]; + // Use pending attributes as the base if they exist (e.g., from a prior updateProps + // that deferred during composition), to avoid clobbering deferred text-style changes. NSMutableDictionary *defaultAttributes = - [_backedTextInputView.defaultTextAttributes mutableCopy]; + _pendingDefaultTextAttributes + ? [_pendingDefaultTextAttributes mutableCopy] + : [_backedTextInputView.defaultTextAttributes mutableCopy]; defaultAttributes[RCTAttributedStringEventEmitterKey] = RCTWrapEventEmitter(_eventEmitter); @@ -173,6 +177,11 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection _needsUpdateDefaultTextAttributes = YES; _pendingDefaultTextAttributes = [attributes copy]; } else { + // Preserve the event emitter key since RCTNSTextAttributesFromTextAttributes does not include it. + id emitter = _backedTextInputView.defaultTextAttributes[RCTAttributedStringEventEmitterKey]; + if (emitter) { + attributes[RCTAttributedStringEventEmitterKey] = emitter; + } _backedTextInputView.defaultTextAttributes = attributes; } } diff --git a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm b/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm deleted file mode 100644 index 636a18e8392d..000000000000 --- a/packages/react-native/React/Tests/TextInput/RCTTextInputComponentViewIMETests.mm +++ /dev/null @@ -1,719 +0,0 @@ -/* - * 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 "RCTTextInputComponentView.h" - -/** - * Mock UITextField subclass that allows overriding markedTextRange for testing - * IME composition scenarios without requiring actual keyboard input. - */ -@interface RCTMockTextField : RCTUITextField -@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; -@end - -@implementation RCTMockTextField - -- (UITextRange *)markedTextRange -{ - return _mockMarkedTextRange; -} - -@end - -/** - * Mock UITextView subclass for multiline IME composition testing. - */ -@interface RCTMockTextView : RCTUITextView -@property (nonatomic, strong, nullable) UITextRange *mockMarkedTextRange; -@end - -@implementation RCTMockTextView - -- (UITextRange *)markedTextRange -{ - return _mockMarkedTextRange; -} - -@end - -/** - * Helper to create a non-nil UITextRange for simulating IME composition. - */ -static UITextRange *createMockTextRange(UITextField *textField) -{ - UITextPosition *start = textField.beginningOfDocument; - UITextPosition *end = [textField positionFromPosition:start offset:1]; - if (!end) { - end = start; - } - return [textField textRangeFromPosition:start toPosition:end ?: start]; -} - -static UITextRange *createMockTextRangeForTextView(UITextView *textView) -{ - UITextPosition *start = textView.beginningOfDocument; - UITextPosition *end = [textView positionFromPosition:start offset:1]; - if (!end) { - end = start; - } - return [textView textRangeFromPosition:start toPosition:end ?: start]; -} - -#pragma mark - Test class - -@interface RCTTextInputComponentViewIMETests : XCTestCase - -@end - -@implementation RCTTextInputComponentViewIMETests - -#pragma mark - Helpers - -- (RCTTextInputComponentView *)createSingleLineWithMock:(RCTMockTextField **)outMock -{ - RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextField *mock = [[RCTMockTextField alloc] initWithFrame:CGRectZero]; - [cv setValue:mock forKey:@"_backedTextInputView"]; - mock.textInputDelegate = (id)cv; - if (outMock) { - *outMock = mock; - } - return cv; -} - -- (RCTTextInputComponentView *)createMultiLineWithMock:(RCTMockTextView **)outMock -{ - RCTTextInputComponentView *cv = [[RCTTextInputComponentView alloc] initWithFrame:CGRectZero]; - RCTMockTextView *mock = [[RCTMockTextView alloc] initWithFrame:CGRectZero]; - [cv setValue:mock forKey:@"_backedTextInputView"]; - mock.textInputDelegate = (id)cv; - if (outMock) { - *outMock = mock; - } - return cv; -} - -#pragma mark - Korean IME lifecycle - -- (void)testKoreanCompositionLifecycle -{ - // Simulates the full UIKit delegate call sequence for typing "한": - // ㅎ (composing) → 하 (composing) → 한 (composing) → 한 (committed) - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ulAttrs = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - - // Step 1: ㅎ — composition starts - mock.mockMarkedTextRange = nil; - [delegate textInputShouldChangeText:@"ㅎ" inRange:NSMakeRange(0, 0)]; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ulAttrs]; - mock.mockMarkedTextRange = createMockTextRange(mock); - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, @"ㅎ"); - - // Step 2: 하 — vowel added - [delegate textInputShouldChangeText:@"하" inRange:NSMakeRange(0, 1)]; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"하" attributes:ulAttrs]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, @"하"); - - // Step 3: 한 — final consonant added - [delegate textInputShouldChangeText:@"한" inRange:NSMakeRange(0, 1)]; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ulAttrs]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, @"한"); - - // Step 4: commit — markedTextRange cleared, underline removed - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, @"한"); -} - -#pragma mark - Japanese romaji lifecycle - -- (void)testJapaneseRomajiCompositionLifecycle -{ - // k → か → かn → かん → 漢 (candidate) → commit - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - mock.mockMarkedTextRange = createMockTextRange(mock); - - for (NSString *step in @[ @"k", @"か", @"かn", @"かん" ]) { - mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, step); - } - - // Candidate selection - mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢" attributes:ul]; - [delegate textInputDidChange]; - - // Commit - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"漢"]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, @"漢"); -} - -#pragma mark - Chinese Pinyin lifecycle - -- (void)testChinesePinyinCompositionLifecycle -{ - // z → zh → zhong → 中 (candidate) → commit - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - mock.mockMarkedTextRange = createMockTextRange(mock); - - for (NSString *step in @[ @"z", @"zh", @"zho", @"zhon", @"zhong" ]) { - mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, step); - } - - mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; - [delegate textInputDidChange]; - - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, @"中"); -} - -#pragma mark - Korean multi-syllable lifecycle - -- (void)testKoreanMultiSyllableCompositionLifecycle -{ - // 감사 — two syllables: ㄱ→가→감 (commit) → ㅅ→사 (commit) - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - - // First syllable: 감 - mock.mockMarkedTextRange = createMockTextRange(mock); - for (NSString *step in @[ @"ㄱ", @"가", @"감" ]) { - mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; - [delegate textInputDidChange]; - } - - // ㅅ is typed — 감 commits, new syllable starts: 감ㅅ - // System splits: "감" committed + "ㅅ" composing - mock.attributedText = [[NSAttributedString alloc] initWithString:@"감ㅅ" attributes:ul]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, @"감ㅅ"); - - // ㅏ added: 감사 - mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사" attributes:ul]; - [delegate textInputDidChange]; - - // Commit - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"감사"]; - [delegate textInputDidChange]; - - XCTAssertEqualObjects(mock.attributedText.string, @"감사"); -} - -#pragma mark - Japanese candidate re-selection lifecycle - -- (void)testJapaneseCandidateReselectionLifecycle -{ - // はし → 橋 → 箸 → 端 → 箸 (user scrolls candidates and confirms) - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - mock.mockMarkedTextRange = createMockTextRange(mock); - - // Romaji input - for (NSString *step in @[ @"h", @"は", @"はs", @"はし" ]) { - mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; - [delegate textInputDidChange]; - } - - // Candidate selection — user scrolls through candidates - for (NSString *candidate in @[ @"橋", @"箸", @"端", @"箸" ]) { - mock.attributedText = [[NSAttributedString alloc] initWithString:candidate attributes:ul]; - [delegate textInputDidChange]; - // Text should reflect the current candidate, not be replaced by JS - XCTAssertEqualObjects(mock.attributedText.string, candidate); - } - - // Commit - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"箸"]; - [delegate textInputDidChange]; - - XCTAssertEqualObjects(mock.attributedText.string, @"箸"); -} - -#pragma mark - Chinese Zhuyin (Bopomofo) lifecycle - -- (void)testChineseZhuyinCompositionLifecycle -{ - // Zhuyin (Bopomofo) IME used in Taiwan: ㄓㄨㄥ → 中 - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - mock.mockMarkedTextRange = createMockTextRange(mock); - - for (NSString *step in @[ @"ㄓ", @"ㄓㄨ", @"ㄓㄨㄥ" ]) { - mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; - [delegate textInputDidChange]; - XCTAssertEqualObjects(mock.attributedText.string, step); - } - - // Candidate selected - mock.attributedText = [[NSAttributedString alloc] initWithString:@"中" attributes:ul]; - [delegate textInputDidChange]; - - // Commit - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"中"]; - [delegate textInputDidChange]; - - XCTAssertEqualObjects(mock.attributedText.string, @"中"); -} - -#pragma mark - Mixed Latin + CJK lifecycle - -- (void)testMixedLatinAndCJKComposition -{ - // User types "Hello" in Latin, then switches to Japanese IME and types "世界" - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - - // Latin input (no composition) - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello"]; - [delegate textInputDidChange]; - - // Japanese composition starts after "Hello" - mock.mockMarkedTextRange = createMockTextRange(mock); - for (NSString *step in @[ @"Hellos", @"Helloせ", @"Helloせk", @"Helloせか", @"Helloせかi", @"Helloせかい" ]) { - mock.attributedText = [[NSAttributedString alloc] initWithString:step attributes:ul]; - [delegate textInputDidChange]; - } - - // Candidate selected - mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界" attributes:ul]; - [delegate textInputDidChange]; - - // Commit - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"Hello世界"]; - [delegate textInputDidChange]; - - XCTAssertEqualObjects(mock.attributedText.string, @"Hello世界"); -} - -#pragma mark - Composition underline preservation - -- (void)testCompositionUnderlinePreservedDuringStateRoundTrip -{ - // The core bug: during IME composition, the system adds NSUnderlineStyleAttributeName - // to show the composition underline. If _setAttributedString is called (e.g., from a - // Fabric state round-trip), it would replace the attributed text with one that has NO - // underline, destroying the visual composition indicator. Our guard must prevent this. - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - - // Set up text WITH underline (simulating active IME composition) - NSDictionary *withUnderline = @{ - NSFontAttributeName : [UIFont systemFontOfSize:14], - NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), - }; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; - mock.mockMarkedTextRange = createMockTextRange(mock); - - // Attempt to replace with text WITHOUT underline (simulating state round-trip) - NSAttributedString *withoutUnderline = - [[NSAttributedString alloc] initWithString:@"한" - attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL sel = NSSelectorFromString(@"_setAttributedString:"); - if ([cv respondsToSelector:sel]) { - [cv performSelector:sel withObject:withoutUnderline]; - } -#pragma clang diagnostic pop - - // The underline attribute must still be present (guard blocked the replacement) - NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; - XCTAssertNotNil( - resultAttrs[NSUnderlineStyleAttributeName], - @"Composition underline must be preserved — _setAttributedString should be blocked during composition"); -} - -- (void)testCompositionUnderlinePreservedDuringDeferredAttributeUpdate -{ - // When updateEventEmitter defers defaultTextAttributes during composition, - // the underline on the current text must not be disturbed. - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *withUnderline = @{ - NSFontAttributeName : [UIFont systemFontOfSize:14], - NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), - }; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"か" attributes:withUnderline]; - mock.mockMarkedTextRange = createMockTextRange(mock); - - // Simulate deferred attribute update (from updateEventEmitter during composition) - NSDictionary *newAttrs = @{NSFontAttributeName : [UIFont boldSystemFontOfSize:16]}; - [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [cv setValue:newAttrs forKey:@"_pendingDefaultTextAttributes"]; - - // textInputDidChange fires mid-composition — deferred attrs must NOT be applied - [delegate textInputDidChange]; - - // Underline must still be on the text - NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; - XCTAssertNotNil( - resultAttrs[NSUnderlineStyleAttributeName], - @"Composition underline must survive deferred attribute update during composition"); - - // Deferred flag must still be pending - XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); -} - -- (void)testUnderlineRemovedAfterCompositionCommit -{ - // After composition commits, the system removes the underline. - // Our code should allow normal attribute updates at this point. - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - // Mid-composition: underline present - NSDictionary *withUnderline = @{ - NSFontAttributeName : [UIFont systemFontOfSize:14], - NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), - }; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:withUnderline]; - mock.mockMarkedTextRange = createMockTextRange(mock); - [delegate textInputDidChange]; - - // Commit: system removes underline and clears markedTextRange - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" - attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; - [delegate textInputDidChange]; - - // After commit, underline should be gone (system removed it) - NSDictionary *resultAttrs = [mock.attributedText attributesAtIndex:0 effectiveRange:nil]; - XCTAssertNil( - resultAttrs[NSUnderlineStyleAttributeName], - @"Underline should be removed after composition commits"); -} - -#pragma mark - _setAttributedString guard - -- (void)testSetAttributedStringBlockedDuringComposition -{ - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - - mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing"]; - mock.mockMarkedTextRange = createMockTextRange(mock); - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL sel = NSSelectorFromString(@"_setAttributedString:"); - if ([cv respondsToSelector:sel]) { - [cv performSelector:sel - withObject:[[NSAttributedString alloc] initWithString:@"replaced"]]; - } -#pragma clang diagnostic pop - - XCTAssertEqualObjects(mock.attributedText.string, @"composing", - @"Text must not be replaced during composition"); -} - -- (void)testSetAttributedStringAllowedAfterComposition -{ - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - - mock.attributedText = [[NSAttributedString alloc] initWithString:@"old"]; - mock.mockMarkedTextRange = nil; - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL sel = NSSelectorFromString(@"_setAttributedString:"); - if ([cv respondsToSelector:sel]) { - [cv performSelector:sel - withObject:[[NSAttributedString alloc] initWithString:@"new"]]; - } -#pragma clang diagnostic pop - - XCTAssertEqualObjects(mock.attributedText.string, @"new", - @"Text should be replaced when not composing"); -} - -#pragma mark - Deferred defaultTextAttributes - -- (void)testDeferredAttributesPreservedDuringComposition -{ - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - - [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} - forKey:@"_pendingDefaultTextAttributes"]; - - mock.mockMarkedTextRange = createMockTextRange(mock); - [(id)cv textInputDidChange]; - - XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); - XCTAssertNotNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); -} - -- (void)testDeferredAttributesAppliedAfterComposition -{ - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"test"]; - - [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [cv setValue:@{NSFontAttributeName : [UIFont systemFontOfSize:18]} - forKey:@"_pendingDefaultTextAttributes"]; - - mock.mockMarkedTextRange = nil; - [(id)cv textInputDidChange]; - - XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); - XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); -} - -#pragma mark - Full lifecycle with deferred attributes - -- (void)testCompositionLifecycleWithDeferredAttributes -{ - // Composition starts → deferred attrs set mid-composition → composition ends → attrs applied - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - id delegate = (id)cv; - - NSDictionary *ul = @{NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle)}; - - // Composition starts - mock.mockMarkedTextRange = createMockTextRange(mock); - mock.attributedText = [[NSAttributedString alloc] initWithString:@"ㅎ" attributes:ul]; - [delegate textInputDidChange]; - - // Mid-composition: deferred attribute update arrives - [cv setValue:@YES forKey:@"_needsUpdateDefaultTextAttributes"]; - [cv setValue:@{NSFontAttributeName : [UIFont boldSystemFontOfSize:20]} - forKey:@"_pendingDefaultTextAttributes"]; - - mock.attributedText = [[NSAttributedString alloc] initWithString:@"한" attributes:ul]; - [delegate textInputDidChange]; - - // Still composing — deferred must be preserved - XCTAssertTrue([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); - - // Commit - mock.mockMarkedTextRange = nil; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"한"]; - [delegate textInputDidChange]; - - // Deferred attrs must now be applied - XCTAssertFalse([[cv valueForKey:@"_needsUpdateDefaultTextAttributes"] boolValue]); - XCTAssertNil([cv valueForKey:@"_pendingDefaultTextAttributes"]); - XCTAssertEqualObjects(mock.attributedText.string, @"한"); -} - -#pragma mark - maxLength - -- (void)testMaxLengthBypassedDuringComposition -{ - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; - mock.mockMarkedTextRange = createMockTextRange(mock); - - NSString *result = - [(id)cv textInputShouldChangeText:@"한" inRange:NSMakeRange(5, 0)]; - XCTAssertEqualObjects(result, @"한"); -} - -- (void)testMaxLengthPassthroughWhenNotComposing -{ - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"12345"]; - mock.mockMarkedTextRange = nil; - - NSString *result = - [(id)cv textInputShouldChangeText:@"6" inRange:NSMakeRange(5, 0)]; - XCTAssertEqualObjects(result, @"6"); -} - -#pragma mark - Multiline bare text comparison - -- (void)testMultilineBareTextComparisonPreventsFalsePositive -{ - RCTMockTextView *mock; - RCTTextInputComponentView *cv = [self createMultiLineWithMock:&mock]; - - mock.attributedText = - [[NSMutableAttributedString alloc] initWithString:@"test" - attributes:@{ - NSFontAttributeName : [UIFont systemFontOfSize:14], - NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), - }]; - - NSAttributedString *withoutUL = - [[NSAttributedString alloc] initWithString:@"test" - attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:14]}]; - [cv setValue:withoutUL forKey:@"_lastStringStateWasUpdatedWith"]; - - // Bare strings equal — new comparison returns YES (no false positive) - XCTAssertTrue([withoutUL.string isEqualToString:mock.attributedText.string]); - // Full attributed strings differ — old comparison would return NO - XCTAssertFalse([withoutUL isEqual:mock.attributedText]); -} - -#pragma mark - JS-driven update blocked during composition - -- (void)testJSDrivenTextUpdateBlockedDuringComposition -{ - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - - mock.attributedText = [[NSAttributedString alloc] initWithString:@"composing" - attributes:@{ - NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), - }]; - mock.mockMarkedTextRange = createMockTextRange(mock); - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL sel = NSSelectorFromString(@"_setAttributedString:"); - if ([cv respondsToSelector:sel]) { - [cv performSelector:sel - withObject:[[NSAttributedString alloc] initWithString:@"js-value"]]; - } -#pragma clang diagnostic pop - - XCTAssertEqualObjects(mock.attributedText.string, @"composing", - @"JS-driven update must be blocked during composition"); -} - -#pragma mark - typingAttributes stripping for IME composition underline - -- (void)testTypingAttributesStrippedForMultiline -{ - // UITextView (multiline): setDefaultTextAttributes: must strip EventEmitter, - // no-op NSShadow, and transparent NSBackgroundColor from typingAttributes. - // These attributes prevent UIKit from rendering the IME composition underline. - RCTMockTextView *mock; - [self createMultiLineWithMock:&mock]; - - NSShadow *noopShadow = [NSShadow new]; // offset (0,0), blur 0 - NSDictionary *attrs = @{ - NSFontAttributeName : [UIFont systemFontOfSize:14], - NSForegroundColorAttributeName : [UIColor blackColor], - @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], - NSShadowAttributeName : noopShadow, - NSBackgroundColorAttributeName : [UIColor clearColor], - }; - mock.defaultTextAttributes = attrs; - - XCTAssertNil(mock.typingAttributes[@"EventEmitter"], - @"EventEmitter must be stripped from typingAttributes"); - XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], - @"No-op NSShadow must be stripped from typingAttributes"); - XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], - @"Transparent NSBackgroundColor must be stripped from typingAttributes"); - XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], - @"Standard attributes like font must be preserved"); - XCTAssertNotNil(mock.typingAttributes[NSForegroundColorAttributeName], - @"Standard attributes like foreground color must be preserved"); -} - -- (void)testRealShadowAndBackgroundPreservedInMultilineTypingAttributes -{ - // User-specified shadow and background color must NOT be stripped. - RCTMockTextView *mock; - [self createMultiLineWithMock:&mock]; - - NSShadow *realShadow = [NSShadow new]; - realShadow.shadowOffset = CGSizeMake(1, 1); - realShadow.shadowBlurRadius = 2; - NSDictionary *attrs = @{ - NSFontAttributeName : [UIFont systemFontOfSize:14], - NSShadowAttributeName : realShadow, - NSBackgroundColorAttributeName : [UIColor yellowColor], - }; - mock.defaultTextAttributes = attrs; - - XCTAssertNotNil(mock.typingAttributes[NSShadowAttributeName], - @"User-specified NSShadow must be preserved"); - XCTAssertNotNil(mock.typingAttributes[NSBackgroundColorAttributeName], - @"User-specified NSBackgroundColor must be preserved"); -} - -- (void)testUpdateTypingAttributesStripsIMEBlockingAttrs -{ - // _updateTypingAttributes reads from attributed text (which has EventEmitter - // and no-op attrs from Fabric). These must be stripped so UIKit can render - // the composition underline on subsequent compositions. - RCTMockTextField *mock; - RCTTextInputComponentView *cv = [self createSingleLineWithMock:&mock]; - - NSShadow *noopShadow = [NSShadow new]; - NSDictionary *attrsWithJunk = @{ - NSFontAttributeName : [UIFont systemFontOfSize:14], - @"EventEmitter" : [@"fake-emitter" dataUsingEncoding:NSUTF8StringEncoding], - NSShadowAttributeName : noopShadow, - NSBackgroundColorAttributeName : [UIColor clearColor], - }; - mock.attributedText = [[NSAttributedString alloc] initWithString:@"test" attributes:attrsWithJunk]; - mock.mockMarkedTextRange = nil; - mock.selectedTextRange = [mock textRangeFromPosition:mock.beginningOfDocument toPosition:mock.beginningOfDocument]; - - [(id)cv textInputDidChangeSelection]; - - XCTAssertNil(mock.typingAttributes[@"EventEmitter"], - @"EventEmitter must be stripped from typingAttributes"); - XCTAssertNil(mock.typingAttributes[NSShadowAttributeName], - @"No-op NSShadow must be stripped from typingAttributes"); - XCTAssertNil(mock.typingAttributes[NSBackgroundColorAttributeName], - @"Transparent NSBackgroundColor must be stripped from typingAttributes"); - XCTAssertNotNil(mock.typingAttributes[NSFontAttributeName], - @"Font must be preserved in typingAttributes"); -} - -@end