From 75f396f1b8cbfbd9714d00194bff3e45ddbe9daf Mon Sep 17 00:00:00 2001 From: gooddev97 <135578805+gooddev97@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:55:35 +0700 Subject: [PATCH] Fix iOS crash when truncating composed text --- .../Tests/Text/RCTTextLayoutManagerTests.mm | 163 ++++++++++++++++++ .../textlayoutmanager/RCTTextLayoutManager.mm | 94 ++++++---- 2 files changed, 219 insertions(+), 38 deletions(-) create mode 100644 packages/react-native/React/Tests/Text/RCTTextLayoutManagerTests.mm diff --git a/packages/react-native/React/Tests/Text/RCTTextLayoutManagerTests.mm b/packages/react-native/React/Tests/Text/RCTTextLayoutManagerTests.mm new file mode 100644 index 000000000000..241b1dcb9d1a --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTTextLayoutManagerTests.mm @@ -0,0 +1,163 @@ +/* + * 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 + +#include + +using namespace facebook::react; + +@interface RCTTextLayoutManager (Tests) + +- (void)processTruncatedAttributedText:(NSTextStorage *)textStorage + textContainer:(NSTextContainer *)textContainer + layoutManager:(NSLayoutManager *)layoutManager; + +@end + +@interface RCTTruncatingLayoutManager : NSLayoutManager + +@property (nonatomic, assign) BOOL isEnumeratingLineFragments; + +@end + +@implementation RCTTruncatingLayoutManager + +- (void)ensureLayoutForTextContainer:(__unused NSTextContainer *)textContainer +{ +} + +- (NSRange)glyphRangeForTextContainer:(__unused NSTextContainer *)textContainer +{ + return NSMakeRange(0, 4); +} + +- (void)enumerateLineFragmentsForGlyphRange:(NSRange)glyphRange + usingBlock:(void (^)( + CGRect rect, + CGRect usedRect, + NSTextContainer *textContainer, + NSRange glyphRange, + BOOL *stop))block +{ + self.isEnumeratingLineFragments = YES; + BOOL stop = NO; + block(CGRectZero, CGRectZero, self.textContainers.firstObject, glyphRange, &stop); + self.isEnumeratingLineFragments = NO; +} + +- (NSRange)truncatedGlyphRangeInLineFragmentForGlyphAtIndex:(__unused NSUInteger)glyphIndex +{ + return NSMakeRange(2, 1); +} + +- (NSRange)characterRangeForGlyphRange:(NSRange)glyphRange actualGlyphRange:(__unused NSRangePointer)actualGlyphRange +{ + return glyphRange; +} + +@end + +@interface RCTTextStorageMutationObserver : NSObject + +@property (nonatomic, weak) RCTTruncatingLayoutManager *layoutManager; +@property (nonatomic, assign) BOOL mutatedWhileEnumeratingLineFragments; +@property (nonatomic, assign) NSRange firstEditedRange; + +@end + +@implementation RCTTextStorageMutationObserver + +- (void)textStorage:(__unused NSTextStorage *)textStorage + willProcessEditing:(__unused NSTextStorageEditActions)editedMask + range:(NSRange)editedRange + changeInLength:(__unused NSInteger)delta +{ + if (self.firstEditedRange.location == NSNotFound) { + self.firstEditedRange = editedRange; + } + if (self.layoutManager.isEnumeratingLineFragments) { + self.mutatedWhileEnumeratingLineFragments = YES; + } +} + +@end + +@interface RCTTextLayoutManagerTests : XCTestCase +@end + +@implementation RCTTextLayoutManagerTests + +- (void)testProcessingTruncatedTextMutatesAfterEnumeratingCompleteCharacterSequences +{ + // The combining acute accent at index 2 belongs to the composed sequence that starts at index 1. + NSTextStorage *textStorage = + [[NSTextStorage alloc] initWithString:@"ae\u0301b" + attributes:@{NSForegroundColorAttributeName : UIColor.blackColor}]; + RCTTruncatingLayoutManager *layoutManager = [RCTTruncatingLayoutManager new]; + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(20, 40)]; + textContainer.maximumNumberOfLines = 1; + [layoutManager addTextContainer:textContainer]; + RCTTextStorageMutationObserver *mutationObserver = [RCTTextStorageMutationObserver new]; + mutationObserver.layoutManager = layoutManager; + mutationObserver.firstEditedRange = NSMakeRange(NSNotFound, 0); + textStorage.delegate = mutationObserver; + + RCTTextLayoutManager *textLayoutManager = [RCTTextLayoutManager new]; + [textLayoutManager processTruncatedAttributedText:textStorage + textContainer:textContainer + layoutManager:layoutManager]; + + XCTAssertFalse(mutationObserver.mutatedWhileEnumeratingLineFragments); + XCTAssertTrue(NSEqualRanges(mutationObserver.firstEditedRange, NSMakeRange(1, 2))); +} + +- (void)testDrawingTruncatedComposedCharactersDoesNotThrow +{ + AttributedString attributedString; + AttributedString::Fragment fragment; + fragment.string = "◍πŸ₯Ϋͺ〬.ΰ ­β€Ώπ“šπ”€π“œβ—πŸ₯­Ϋͺ〬"; + fragment.textAttributes.fontSize = 17; + fragment.textAttributes.foregroundColor = blackColor(); + fragment.textAttributes.backgroundColor = clearColor(); + fragment.textAttributes.isHighlighted = true; + attributedString.appendFragment(std::move(fragment)); + + ParagraphAttributes paragraphAttributes; + paragraphAttributes.maximumNumberOfLines = 1; + paragraphAttributes.ellipsizeMode = EllipsizeMode::Tail; + + RCTTextLayoutManager *textLayoutManager = [RCTTextLayoutManager new]; + + for (CGFloat width = 20; width <= 200; width += 0.5) { + NSException *caughtException = nil; + UIGraphicsBeginImageContextWithOptions(CGSizeMake(width, 40), NO, 1); + @try { + [textLayoutManager drawAttributedString:attributedString + paragraphAttributes:paragraphAttributes + frame:CGRectMake(0, 0, width, 40) + drawHighlightPath:^(__unused UIBezierPath *highlightPath){ + }]; + } @catch (NSException *exception) { + caughtException = exception; + } @finally { + UIGraphicsEndImageContext(); + } + + XCTAssertNil(caughtException, @"Drawing truncated text threw at width %.1f: %@", width, caughtException); + if (caughtException != nil) { + break; + } + } +} + +@end diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 5f9847a398fa..e0e0e12ead31 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -290,31 +290,34 @@ - (void)drawAttributedString:(AttributedString)attributedString if (block != nil) { __block UIBezierPath *highlightPath = nil; NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; + characterRange = NSIntersectionRange(characterRange, NSMakeRange(0, textStorage.length)); - [textStorage - enumerateAttribute:RCTAttributedStringIsHighlightedAttributeName - inRange:characterRange - options:0 - usingBlock:^(NSNumber *value, NSRange range, __unused BOOL *stop) { - if (!value.boolValue) { - return; - } - - [layoutManager - enumerateEnclosingRectsForGlyphRange:range - withinSelectedGlyphRange:range - inTextContainer:textContainer - usingBlock:^(CGRect enclosingRect, __unused BOOL *anotherStop) { - UIBezierPath *path = [UIBezierPath - bezierPathWithRoundedRect:CGRectInset(enclosingRect, -2, -2) - cornerRadius:2]; - if (highlightPath != nullptr) { - [highlightPath appendPath:path]; - } else { - highlightPath = path; - } - }]; - }]; + if (characterRange.length > 0) { + [textStorage + enumerateAttribute:RCTAttributedStringIsHighlightedAttributeName + inRange:characterRange + options:0 + usingBlock:^(NSNumber *value, NSRange range, __unused BOOL *stop) { + if (!value.boolValue) { + return; + } + + [layoutManager + enumerateEnclosingRectsForGlyphRange:range + withinSelectedGlyphRange:range + inTextContainer:textContainer + usingBlock:^(CGRect enclosingRect, __unused BOOL *anotherStop) { + UIBezierPath *path = [UIBezierPath + bezierPathWithRoundedRect:CGRectInset(enclosingRect, -2, -2) + cornerRadius:2]; + if (highlightPath != nullptr) { + [highlightPath appendPath:path]; + } else { + highlightPath = path; + } + }]; + }]; + } block(highlightPath); } @@ -328,6 +331,7 @@ - (void)processTruncatedAttributedText:(NSTextStorage *)textStorage [layoutManager ensureLayoutForTextContainer:textContainer]; NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; __block int line = 0; + __block NSRange truncatedCharacterRange = NSMakeRange(NSNotFound, 0); [layoutManager enumerateLineFragmentsForGlyphRange:glyphRange usingBlock:^( @@ -340,26 +344,40 @@ - (void)processTruncatedAttributedText:(NSTextStorage *)textStorage NSRange truncatedRange = [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lineGlyphRange.location]; if (truncatedRange.location != NSNotFound) { - NSRange characterRange = + truncatedCharacterRange = [layoutManager characterRangeForGlyphRange:truncatedRange actualGlyphRange:nil]; - if (characterRange.location > 0 && characterRange.length > 0) { - // Remove color attributes for truncated range - for (NSAttributedStringKey key in - @[ NSForegroundColorAttributeName, NSBackgroundColorAttributeName ]) { - [textStorage removeAttribute:key range:characterRange]; - id attribute = [textStorage attribute:key - atIndex:characterRange.location - 1 - effectiveRange:nil]; - if (attribute != nullptr) { - [textStorage addAttribute:key value:attribute range:characterRange]; - } - } - } } } line++; }]; + + if (truncatedCharacterRange.location == NSNotFound) { + return; + } + + NSRange textStorageRange = NSMakeRange(0, textStorage.length); + truncatedCharacterRange = NSIntersectionRange(truncatedCharacterRange, textStorageRange); + if (truncatedCharacterRange.length == 0) { + return; + } + + truncatedCharacterRange = [textStorage.string rangeOfComposedCharacterSequencesForRange:truncatedCharacterRange]; + truncatedCharacterRange = NSIntersectionRange(truncatedCharacterRange, textStorageRange); + if (truncatedCharacterRange.location == 0 || truncatedCharacterRange.length == 0) { + return; + } + + [textStorage beginEditing]; + // Remove color attributes for truncated range + for (NSAttributedStringKey key in @[ NSForegroundColorAttributeName, NSBackgroundColorAttributeName ]) { + [textStorage removeAttribute:key range:truncatedCharacterRange]; + id attribute = [textStorage attribute:key atIndex:truncatedCharacterRange.location - 1 effectiveRange:nil]; + if (attribute != nullptr) { + [textStorage addAttribute:key value:attribute range:truncatedCharacterRange]; + } + } + [textStorage endEditing]; } }