Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSAttributedStringKey, id> *_pendingDefaultTextAttributes;
}

#pragma mark - UIView overrides
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -143,8 +160,14 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
UITraitCollection.currentTraitCollection.preferredContentSizeCategory !=
previousTraitCollection.preferredContentSizeCategory) {
const auto &newTextInputProps = static_cast<const TextInputProps &>(*_props);
_backedTextInputView.defaultTextAttributes =
NSDictionary<NSAttributedStringKey, id> *attributes =
RCTNSTextAttributesFromTextAttributes(newTextInputProps.getEffectiveTextAttributes(RCTFontSizeMultiplier()));
if (_backedTextInputView.markedTextRange) {
_needsUpdateDefaultTextAttributes = YES;
_pendingDefaultTextAttributes = [attributes copy];
} else {
_backedTextInputView.defaultTextAttributes = attributes;
}
}
}

Expand Down Expand Up @@ -297,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) {
Expand Down Expand Up @@ -384,6 +412,8 @@ - (void)prepareForRecycle
_lastStringStateWasUpdatedWith = nil;
_ignoreNextTextInputCall = NO;
_didMoveToWindow = NO;
_needsUpdateDefaultTextAttributes = NO;
_pendingDefaultTextAttributes = nil;
_backedTextInputView.inputAccessoryViewID = nil;
_backedTextInputView.inputAccessoryView = nil;
_hasInputAccessoryView = false;
Expand Down Expand Up @@ -457,7 +487,9 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
}
}

if (props.maxLength < std::numeric_limits<int>::max()) {
// Defer maxLength enforcement during IME composition — it will be applied
// after the composition is committed (in textInputDidChange).
if (props.maxLength < std::numeric_limits<int>::max() && !_backedTextInputView.markedTextRange) {
NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length;

if (allowedLength > 0 && text.length > allowedLength) {
Expand Down Expand Up @@ -495,6 +527,41 @@ - (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<const TextInputProps &>(*_props);
if (props.maxLength < std::numeric_limits<int>::max()) {
NSString *currentText = _backedTextInputView.attributedText.string;
if ((NSInteger)currentText.length > props.maxLength) {
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];
}
}
}
}

[self _updateState];

if (_eventEmitter) {
Expand All @@ -515,7 +582,8 @@ - (void)textInputDidChangeSelection
[self _updateTypingAttributes];

const auto &props = static_cast<const TextInputProps &>(*_props);
if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
if (props.multiline &&
![_lastStringStateWasUpdatedWith.string isEqualToString:_backedTextInputView.attributedText.string]) {
[self textInputDidChange];
_ignoreNextTextInputCall = YES;
}
Expand Down Expand Up @@ -575,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
Expand Down Expand Up @@ -768,6 +841,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;
}
Expand Down
Loading
Loading