Skip to content

Commit c424c38

Browse files
Saadnajmiclaude
andauthored
fix(0.81): TextInput Fabric commands - setGhostText, blur, and selection fixes (#2937)
## Summary Backport of #2912 to 0.81-stable. - **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 d07e3c6 commit c424c38

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
@@ -103,6 +103,10 @@ @implementation RCTTextInputComponentView {
103103

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

108112
#pragma mark - UIView overrides
@@ -514,6 +518,10 @@ - (void)prepareForRecycle
514518
_lastStringStateWasUpdatedWith = nil;
515519
_ignoreNextTextInputCall = NO;
516520
_didMoveToWindow = NO;
521+
#if TARGET_OS_OSX // [macOS
522+
_ghostText = nil;
523+
_ghostTextPosition = 0;
524+
#endif // macOS]
517525
[_backedTextInputView resignFirstResponder];
518526
}
519527

@@ -538,6 +546,10 @@ - (BOOL)textInputShouldEndEditing
538546

539547
- (void)textInputDidEndEditing
540548
{
549+
#if TARGET_OS_OSX // [macOS
550+
[self setGhostText:nil];
551+
#endif // macOS]
552+
541553
if (_eventEmitter) {
542554
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onEndEditing([self _textInputMetrics]);
543555
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onBlur([self _textInputMetrics]);
@@ -572,6 +584,12 @@ - (void)textInputDidReturn
572584

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

577595
if (!_backedTextInputView.textWasPasted) {
@@ -617,6 +635,17 @@ - (void)textInputDidChange
617635
return;
618636
}
619637

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

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

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

810956
- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
@@ -842,7 +988,13 @@ - (void)blur
842988
[_backedTextInputView resignFirstResponder];
843989
#else // [macOS
844990
NSWindow *window = [_backedTextInputView window];
845-
if ([window firstResponder] == _backedTextInputView.responder) {
991+
// On macOS, when an NSTextField is focused, the window's firstResponder is the
992+
// field editor (an NSTextView), not the text field itself. Check currentEditor
993+
// to determine if the text field is actively being edited.
994+
if ([_backedTextInputView isKindOfClass:[NSTextField class]] &&
995+
[(NSTextField *)_backedTextInputView currentEditor] != nil) {
996+
[window makeFirstResponder:nil];
997+
} else if ([window firstResponder] == _backedTextInputView.responder) {
846998
[window makeFirstResponder:nil];
847999
}
8481000
#endif // macOS]
@@ -881,7 +1033,7 @@ - (void)setTextAndSelection:(NSInteger)eventCount
8811033
#else // [macOS
8821034
NSInteger startPosition = MIN(start, end);
8831035
NSInteger endPosition = MAX(start, end);
884-
[_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:YES];
1036+
[_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:NO];
8851037
#endif // macOS]
8861038
_comingFromJS = NO;
8871039
}

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)