Skip to content

Commit eda57a2

Browse files
Saadnajmiclaude
andauthored
fix(macos): TextInput Fabric commands - setGhostText, blur, and selection fixes (#2912)
## Summary TextInput commands (`setGhostText`, `blur`, `setSelection`) don't work correctly under Fabric on macOS. This PR fixes three issues: - **Add missing `setGhostText` command to Fabric**: Ghost text was implemented for Paper in #1890 / #1897 (with undo fixes in #2105 / #2106), but the Fabric command dispatcher and component view never got the implementation. This ports the ghost text logic (insertion, removal, attributes, delegate cleanup) from `RCTBaseTextInputView` to `RCTTextInputComponentView`. - **Fix `blur` command on macOS Fabric**: `blur` was a silent no-op because it compared `[window firstResponder]` against the NSTextField, but on macOS the firstResponder during editing is the field editor (NSTextView), not the text field itself. Fixed by checking `currentEditor` on NSTextField. - **Fix ghost text being selectable on Fabric**: Ghost text could be selected by the user on Fabric (but not Paper). Added cleanup in `textInputDidChangeSelection` to clear ghost text when the user changes selection, matching Paper behavior. - **Fix `setTextAndSelection` delegate notification**: Changed `notifyDelegate:YES` to `notifyDelegate:NO` to match Paper's `setSelectionStart:selectionEnd:` behavior and prevent spurious delegate callbacks during programmatic selection changes. ## Test plan - [x] Verified `setGhostText` command works via RNTester on macOS Fabric - [x] Verified `blur` command works on macOS Fabric - [x] Verified ghost text is not selectable on Fabric - [x] Verified `focus`, `clear`, `setSelection` commands still work - [x] JS TextInput tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8fa8f22 commit eda57a2

4 files changed

Lines changed: 191 additions & 6 deletions

File tree

packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ jest.unmock('../TextInput');
6565

6666
expect(inputElement.isFocused).toBeInstanceOf(Function); // Would have prevented S168585
6767
expect(inputElement.clear).toBeInstanceOf(Function);
68+
// [macOS
69+
expect(inputElement.setSelection).toBeInstanceOf(Function);
70+
expect(inputElement.setGhostText).toBeInstanceOf(Function);
71+
// macOS]
6872
// $FlowFixMe[method-unbinding]
6973
expect(inputElement.focus).toBeInstanceOf(jest.fn().constructor);
7074
// $FlowFixMe[method-unbinding]

packages/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,19 @@ - (void)setAttributedText:(NSAttributedString *)attributedText
328328
#if !TARGET_OS_OSX // [macOS]
329329
[super setAttributedText:attributedText];
330330
#else // [macOS
331-
// Break undo coalescing when the text is changed by JS (e.g. autocomplete).
332-
[self breakUndoCoalescing];
333-
// Avoid Exception thrown while executing UI block: *** -[NSBigMutableString replaceCharactersInRange:withString:]: nil argument
334-
[self.textStorage setAttributedString:attributedText ?: [NSAttributedString new]];
331+
if (self.ghostTextChanging) {
332+
// Ghost text changes should not be on the undo stack. Disable undo
333+
// registration around the text storage mutation so Cmd+Z skips over
334+
// ghost text insertions/removals.
335+
[self.undoManager disableUndoRegistration];
336+
[self.textStorage setAttributedString:attributedText ?: [NSAttributedString new]];
337+
[self.undoManager enableUndoRegistration];
338+
} else {
339+
// Break undo coalescing when the text is changed by JS (e.g. autocomplete).
340+
[self breakUndoCoalescing];
341+
// Avoid Exception thrown while executing UI block: *** -[NSBigMutableString replaceCharactersInRange:withString:]: nil argument
342+
[self.textStorage setAttributedString:attributedText ?: [NSAttributedString new]];
343+
}
335344
#endif // macOS]
336345
[self textDidChange];
337346
}

packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ @implementation RCTTextInputComponentView {
102102

103103
BOOL _hasInputAccessoryView;
104104
CGSize _previousContentSize;
105+
#if TARGET_OS_OSX // [macOS
106+
NSString *_ghostText;
107+
NSInteger _ghostTextPosition;
108+
#endif // macOS]
105109
}
106110

107111
#pragma mark - UIView overrides
@@ -513,6 +517,10 @@ - (void)prepareForRecycle
513517
_lastStringStateWasUpdatedWith = nil;
514518
_ignoreNextTextInputCall = NO;
515519
_didMoveToWindow = NO;
520+
#if TARGET_OS_OSX // [macOS
521+
_ghostText = nil;
522+
_ghostTextPosition = 0;
523+
#endif // macOS]
516524
[_backedTextInputView resignFirstResponder];
517525
}
518526

@@ -537,6 +545,10 @@ - (BOOL)textInputShouldEndEditing
537545

538546
- (void)textInputDidEndEditing
539547
{
548+
#if TARGET_OS_OSX // [macOS
549+
[self setGhostText:nil];
550+
#endif // macOS]
551+
540552
if (_eventEmitter) {
541553
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onEndEditing([self _textInputMetrics]);
542554
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onBlur([self _textInputMetrics]);
@@ -571,6 +583,12 @@ - (void)textInputDidReturn
571583

572584
- (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
573585
{
586+
#if TARGET_OS_OSX // [macOS
587+
// Clear ghost text before the text change so the undo manager's snapshot
588+
// of the pre-edit state never contains ghost text.
589+
[self setGhostText:nil];
590+
#endif // macOS]
591+
574592
const auto &props = static_cast<const TextInputProps &>(*_props);
575593

576594
if (!_backedTextInputView.textWasPasted) {
@@ -616,6 +634,17 @@ - (void)textInputDidChange
616634
return;
617635
}
618636

637+
#if TARGET_OS_OSX // [macOS
638+
if (_ghostText != nil) {
639+
NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString:_backedTextInputView.attributedText strict:NO];
640+
if (attributedStringWithoutGhostText != nil && ![attributedStringWithoutGhostText isEqual:_backedTextInputView.attributedText]) {
641+
_backedTextInputView.attributedText = attributedStringWithoutGhostText;
642+
}
643+
_ghostText = nil;
644+
_ghostTextPosition = 0;
645+
}
646+
#endif // macOS]
647+
619648
if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
620649
_ignoreNextTextInputCall = NO;
621650
return;
@@ -631,6 +660,14 @@ - (void)textInputDidChange
631660

632661
- (void)textInputDidChangeSelection
633662
{
663+
#if TARGET_OS_OSX // [macOS
664+
// Clear ghost text on any user selection change, matching Paper behavior.
665+
// This prevents the user from selecting ghost text.
666+
if (_ghostText != nil && !_comingFromJS && !_backedTextInputView.ghostTextChanging) {
667+
[self setGhostText:nil];
668+
}
669+
#endif // macOS]
670+
634671
if (_comingFromJS) {
635672
return;
636673
}
@@ -804,6 +841,115 @@ - (void)scrollViewDidScroll:(RCTUIScrollView *)scrollView // [macOS]
804841
}
805842
}
806843

844+
#if TARGET_OS_OSX // [macOS
845+
#pragma mark - Ghost Text
846+
847+
- (NSDictionary<NSAttributedStringKey, id> *)ghostTextAttributes
848+
{
849+
NSMutableDictionary<NSAttributedStringKey, id> *textAttributes =
850+
[_backedTextInputView.defaultTextAttributes mutableCopy] ?: [NSMutableDictionary new];
851+
852+
[textAttributes setValue:_backedTextInputView.placeholderColor ?: [RCTPlatformColor placeholderTextColor]
853+
forKey:NSForegroundColorAttributeName];
854+
855+
return textAttributes;
856+
}
857+
858+
- (void)setGhostText:(NSString *)ghostText
859+
{
860+
NSRange selectedRange = [_backedTextInputView selectedTextRange];
861+
NSInteger selectionStart = selectedRange.location;
862+
NSInteger selectionEnd = selectedRange.location + selectedRange.length;
863+
NSString *newGhostText = ghostText.length > 0 ? ghostText : nil;
864+
865+
if (selectionStart != selectionEnd) {
866+
newGhostText = nil;
867+
}
868+
869+
if ((_ghostText == nil && newGhostText == nil) || [_ghostText isEqual:newGhostText]) {
870+
return;
871+
}
872+
873+
if (_backedTextInputView.ghostTextChanging) {
874+
// look out for nested callbacks -- this can happen for example when selection changes in response to
875+
// attributed text changing. Such callbacks are initiated by Apple, or we could suppress this other ways.
876+
return;
877+
}
878+
879+
_backedTextInputView.ghostTextChanging = YES;
880+
881+
if (_ghostText != nil) {
882+
// When setGhostText: is called after making a standard edit, the ghost text may already be gone
883+
BOOL ghostTextMayAlreadyBeGone = newGhostText == nil;
884+
NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString:_backedTextInputView.attributedText strict:!ghostTextMayAlreadyBeGone];
885+
886+
if (attributedStringWithoutGhostText != nil) {
887+
_backedTextInputView.attributedText = attributedStringWithoutGhostText;
888+
[_backedTextInputView setSelectedTextRange:NSMakeRange(selectionStart, selectionEnd - selectionStart) notifyDelegate:NO];
889+
}
890+
}
891+
892+
_ghostText = [newGhostText copy];
893+
_ghostTextPosition = selectionStart;
894+
895+
if (_ghostText != nil) {
896+
NSMutableAttributedString *attributedString = [_backedTextInputView.attributedText mutableCopy];
897+
NSAttributedString *ghostAttributedString = [[NSAttributedString alloc] initWithString:_ghostText
898+
attributes:self.ghostTextAttributes];
899+
900+
[attributedString insertAttributedString:ghostAttributedString atIndex:_ghostTextPosition];
901+
_backedTextInputView.attributedText = attributedString;
902+
[_backedTextInputView setSelectedTextRange:NSMakeRange(_ghostTextPosition, 0) notifyDelegate:NO];
903+
}
904+
905+
_backedTextInputView.ghostTextChanging = NO;
906+
}
907+
908+
/**
909+
* Attempts to remove the ghost text from a provided string given our current state.
910+
*
911+
* If `strict` mode is enabled, this method assumes the ghost text exists exactly
912+
* where we expect it to be. We assert and return `nil` if we don't find the expected ghost text.
913+
* It's the responsibility of the caller to make sure the result isn't `nil`.
914+
*
915+
* If disabled, we allow for the possibility that the ghost text has already been removed,
916+
* which can happen if a delegate callback is trying to remove ghost text after invoking `setAttributedText:`.
917+
*/
918+
- (NSAttributedString *)removingGhostTextFromString:(NSAttributedString *)string strict:(BOOL)strict
919+
{
920+
if (_ghostText == nil) {
921+
return string;
922+
}
923+
924+
NSRange ghostTextRange = NSMakeRange(_ghostTextPosition, _ghostText.length);
925+
NSMutableAttributedString *attributedString = [string mutableCopy];
926+
927+
if ([attributedString length] < NSMaxRange(ghostTextRange)) {
928+
if (strict) {
929+
RCTAssert(false, @"Ghost text not fully present in text view text");
930+
return nil;
931+
} else {
932+
return string;
933+
}
934+
}
935+
936+
NSString *actualGhostText = [[attributedString attributedSubstringFromRange:ghostTextRange] string];
937+
938+
if (![actualGhostText isEqual:_ghostText]) {
939+
if (strict) {
940+
RCTAssert(false, @"Ghost text does not match text view text");
941+
return nil;
942+
} else {
943+
return string;
944+
}
945+
}
946+
947+
[attributedString deleteCharactersInRange:ghostTextRange];
948+
return attributedString;
949+
}
950+
951+
#endif // macOS]
952+
807953
#pragma mark - Native Commands
808954

809955
- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
@@ -841,7 +987,13 @@ - (void)blur
841987
[_backedTextInputView resignFirstResponder];
842988
#else // [macOS
843989
NSWindow *window = [_backedTextInputView window];
844-
if ([window firstResponder] == _backedTextInputView.responder) {
990+
// On macOS, when an NSTextField is focused, the window's firstResponder is the
991+
// field editor (an NSTextView), not the text field itself. Check currentEditor
992+
// to determine if the text field is actively being edited.
993+
if ([_backedTextInputView isKindOfClass:[NSTextField class]] &&
994+
[(NSTextField *)_backedTextInputView currentEditor] != nil) {
995+
[window makeFirstResponder:nil];
996+
} else if ([window firstResponder] == _backedTextInputView.responder) {
845997
[window makeFirstResponder:nil];
846998
}
847999
#endif // macOS]
@@ -880,7 +1032,7 @@ - (void)setTextAndSelection:(NSInteger)eventCount
8801032
#else // [macOS
8811033
NSInteger startPosition = MIN(start, end);
8821034
NSInteger endPosition = MAX(start, end);
883-
[_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:YES];
1035+
[_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:NO];
8841036
#endif // macOS]
8851037
_comingFromJS = NO;
8861038
}

packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ NS_ASSUME_NONNULL_BEGIN
1818
value:(NSString *__nullable)value
1919
start:(NSInteger)start
2020
end:(NSInteger)end;
21+
#if TARGET_OS_OSX // [macOS
22+
- (void)setGhostText:(NSString *__nullable)ghostText;
23+
#endif // macOS]
2124
@end
2225

2326
RCT_EXTERN inline void
@@ -96,6 +99,23 @@ RCTTextInputHandleCommand(id<RCTTextInputViewProtocol> componentView, const NSSt
9699
return;
97100
}
98101

102+
#if TARGET_OS_OSX // [macOS
103+
if ([commandName isEqualToString:@"setGhostText"]) {
104+
#if RCT_DEBUG
105+
if ([args count] != 1) {
106+
RCTLogError(
107+
@"%@ command %@ received %d arguments, expected %d.", @"TextInput", commandName, (int)[args count], 1);
108+
return;
109+
}
110+
#endif
111+
112+
NSObject *arg0 = args[0];
113+
NSString *value = [arg0 isKindOfClass:[NSNull class]] ? nil : (NSString *)arg0;
114+
[componentView setGhostText:value];
115+
return;
116+
}
117+
#endif // macOS]
118+
99119
#if RCT_DEBUG
100120
RCTLogError(@"%@ received command %@, which is not a supported command.", @"TextInput", commandName);
101121
#endif

0 commit comments

Comments
 (0)