From bc2bc1a3aa5e8e5bdafecbadf83b144766384eec Mon Sep 17 00:00:00 2001 From: Wataru Okada Date: Sat, 31 Oct 2026 22:09:00 +0900 Subject: [PATCH 1/2] Ignore the event emitter attribute when comparing TextInput text on iOS `RCTIsAttributedStringEffectivelySame` decides whether `RCTTextInputComponentView` writes the text JS sent back into the focused field. It compared `RCTAttributedStringEventEmitterKey` like any other attribute, but its value is an NSData holding the address of a heap-allocated weak_ptr, so two wrappers of the same emitter never compare equal, and text typed into the field may not carry the attribute at all. The comparison then reported a difference for identical text and the field was rewritten while the user was typing, which cancels the composition of IMEs that do not use marked text (#58807) and drops keys typed during the write (#58904). The attribute is React-internal metadata only read by Paragraph hit testing; a TextInput never dispatches through it, so it is now ignored. Co-Authored-By: Claude Fable 5.1 --- .../Tests/Text/RCTAttributedTextUtilsTest.mm | 64 +++++++++++++++++++ .../RCTAttributedTextUtils.mm | 8 +++ 2 files changed, 72 insertions(+) diff --git a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm index a02622d5d7d7..34df2a641494 100644 --- a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm +++ b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm @@ -245,6 +245,70 @@ - (void)testSamenessOfMissingInsensitiveAttributes attributedString2, attributedString1, insensitiveAttributes, TextAttributes{})); } +- (void)testSamenessOfDifferentEventEmitterWrappers +{ + // RCTWrapEventEmitter allocates a new wrapper each time, so the text a TextInput state is built from and the text + // the field shows never share one, even for the same emitter. + NSMutableAttributedString *attributedString1 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; + [attributedString1 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 5)]; + [attributedString1 addAttribute:RCTAttributedStringEventEmitterKey + value:RCTWrapEventEmitter(nullptr) + range:NSMakeRange(0, 12)]; + + NSMutableAttributedString *attributedString2 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; + [attributedString2 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 5)]; + [attributedString2 addAttribute:RCTAttributedStringEventEmitterKey + value:RCTWrapEventEmitter(nullptr) + range:NSMakeRange(0, 12)]; + + NSDictionary *insensitiveAttributes = @{}; + + XCTAssertTrue(RCTIsAttributedStringEffectivelySame( + attributedString1, attributedString2, insensitiveAttributes, TextAttributes{})); + XCTAssertTrue(RCTIsAttributedStringEffectivelySame( + attributedString2, attributedString1, insensitiveAttributes, TextAttributes{})); +} + +- (void)testSamenessOfMissingEventEmitter +{ + // Text typed into a field carries no event emitter; the state echoing it does. + NSMutableAttributedString *attributedString1 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; + [attributedString1 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 5)]; + [attributedString1 addAttribute:RCTAttributedStringEventEmitterKey + value:RCTWrapEventEmitter(nullptr) + range:NSMakeRange(0, 12)]; + + NSMutableAttributedString *attributedString2 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; + [attributedString2 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 5)]; + + NSDictionary *insensitiveAttributes = @{}; + + XCTAssertTrue(RCTIsAttributedStringEffectivelySame( + attributedString1, attributedString2, insensitiveAttributes, TextAttributes{})); + XCTAssertTrue(RCTIsAttributedStringEffectivelySame( + attributedString2, attributedString1, insensitiveAttributes, TextAttributes{})); +} + +- (void)testDifferenceOfOtherAttributesNextToEventEmitter +{ + // Ignoring the event emitter must not hide a real difference in the same run. + NSMutableAttributedString *attributedString1 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; + [attributedString1 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 12)]; + [attributedString1 addAttribute:RCTAttributedStringEventEmitterKey + value:RCTWrapEventEmitter(nullptr) + range:NSMakeRange(0, 12)]; + + NSMutableAttributedString *attributedString2 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; + [attributedString2 addAttribute:NSForegroundColorAttributeName value:[UIColor blueColor] range:NSMakeRange(0, 12)]; + + NSDictionary *insensitiveAttributes = @{}; + + XCTAssertFalse(RCTIsAttributedStringEffectivelySame( + attributedString1, attributedString2, insensitiveAttributes, TextAttributes{})); + XCTAssertFalse(RCTIsAttributedStringEffectivelySame( + attributedString2, attributedString1, insensitiveAttributes, TextAttributes{})); +} + - (void)testSamenessOfResolvedParagraphStyleLtr { NSMutableAttributedString *attributedString1 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 791d8d129473..32df0ba8e81b 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -568,6 +568,14 @@ static BOOL RCTIsAttributeEffectivelySame( NSDictionary *insensitiveAttributes, const TextAttributes &baseTextAttributes) { + // The event emitter is React-internal metadata, not something UIKit shows: a TextInput dispatches its events + // through its own emitter, not through this attribute. Its value is an NSData holding the address of a + // heap-allocated weak_ptr (RCTWrapEventEmitter), so two wrappers of the same emitter never compare equal, and + // text typed into the field may not carry it at all. + if ([attributeKey isEqualToString:RCTAttributedStringEventEmitterKey]) { + return YES; + } + id attribute1 = attributes1[attributeKey] ?: insensitiveAttributes[attributeKey]; id attribute2 = attributes2[attributeKey] ?: insensitiveAttributes[attributeKey]; From 7fad9f52c3c6a151ed4d5e9cf5e7d1eb475aed3f Mon Sep 17 00:00:00 2001 From: Wataru Okada Date: Sat, 31 Oct 2026 22:46:54 +0900 Subject: [PATCH 2/2] Test the event emitter comparison with real emitters and correct the comment Two wrappers of the same emitter do compare equal (the NSData holds the pointee address), so the earlier wording was wrong; the mismatch is the attribute present on the state's text and absent from the typed text, which the test without the fix now shows. The second test uses two distinct emitters to document that even a different emitter is not a difference for a TextInput. Co-Authored-By: Claude Fable 5.1 --- .../Tests/Text/RCTAttributedTextUtilsTest.mm | 21 ++++++++++++------- .../RCTAttributedTextUtils.mm | 7 +++---- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm index 34df2a641494..8152757f4a56 100644 --- a/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm +++ b/packages/react-native/React/Tests/Text/RCTAttributedTextUtilsTest.mm @@ -14,6 +14,7 @@ #import #include +#include #include #include @@ -245,20 +246,22 @@ - (void)testSamenessOfMissingInsensitiveAttributes attributedString2, attributedString1, insensitiveAttributes, TextAttributes{})); } -- (void)testSamenessOfDifferentEventEmitterWrappers +- (void)testSamenessOfDifferentEventEmitters { - // RCTWrapEventEmitter allocates a new wrapper each time, so the text a TextInput state is built from and the text - // the field shows never share one, even for the same emitter. + // The emitter is metadata a TextInput never dispatches through, so even a different one is not a difference. + auto eventEmitter1 = std::make_shared(nullptr, EventDispatcher::Weak{}); + auto eventEmitter2 = std::make_shared(nullptr, EventDispatcher::Weak{}); + NSMutableAttributedString *attributedString1 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; [attributedString1 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 5)]; [attributedString1 addAttribute:RCTAttributedStringEventEmitterKey - value:RCTWrapEventEmitter(nullptr) + value:RCTWrapEventEmitter(eventEmitter1) range:NSMakeRange(0, 12)]; NSMutableAttributedString *attributedString2 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; [attributedString2 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 5)]; [attributedString2 addAttribute:RCTAttributedStringEventEmitterKey - value:RCTWrapEventEmitter(nullptr) + value:RCTWrapEventEmitter(eventEmitter2) range:NSMakeRange(0, 12)]; NSDictionary *insensitiveAttributes = @{}; @@ -272,10 +275,12 @@ - (void)testSamenessOfDifferentEventEmitterWrappers - (void)testSamenessOfMissingEventEmitter { // Text typed into a field carries no event emitter; the state echoing it does. + auto eventEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + NSMutableAttributedString *attributedString1 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; [attributedString1 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 5)]; [attributedString1 addAttribute:RCTAttributedStringEventEmitterKey - value:RCTWrapEventEmitter(nullptr) + value:RCTWrapEventEmitter(eventEmitter) range:NSMakeRange(0, 12)]; NSMutableAttributedString *attributedString2 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; @@ -292,10 +297,12 @@ - (void)testSamenessOfMissingEventEmitter - (void)testDifferenceOfOtherAttributesNextToEventEmitter { // Ignoring the event emitter must not hide a real difference in the same run. + auto eventEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + NSMutableAttributedString *attributedString1 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; [attributedString1 addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 12)]; [attributedString1 addAttribute:RCTAttributedStringEventEmitterKey - value:RCTWrapEventEmitter(nullptr) + value:RCTWrapEventEmitter(eventEmitter) range:NSMakeRange(0, 12)]; NSMutableAttributedString *attributedString2 = [[NSMutableAttributedString alloc] initWithString:@"Hello world!"]; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm index 32df0ba8e81b..16b942dadfe9 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm @@ -568,10 +568,9 @@ static BOOL RCTIsAttributeEffectivelySame( NSDictionary *insensitiveAttributes, const TextAttributes &baseTextAttributes) { - // The event emitter is React-internal metadata, not something UIKit shows: a TextInput dispatches its events - // through its own emitter, not through this attribute. Its value is an NSData holding the address of a - // heap-allocated weak_ptr (RCTWrapEventEmitter), so two wrappers of the same emitter never compare equal, and - // text typed into the field may not carry it at all. + // The event emitter is React-internal metadata, not something UIKit shows, and a TextInput dispatches its events + // through its own emitter rather than through this attribute. Text typed into the field often carries no + // emitter while the state echoing it does, so comparing it reports a difference for identical text. if ([attributeKey isEqualToString:RCTAttributedStringEventEmitterKey]) { return YES; }