From 8a1b4fdcdc07c0db036d62ad5ded02da75d5f312 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 9 Apr 2026 17:27:35 -0700 Subject: [PATCH] fix(fabric): implement missing TextInput commands for macOS - Add setGhostText command to Fabric TextInput native handler (was declared in JS but missing from native dispatch) - Port ghost text implementation from Paper to Fabric with proper Fabric API adaptations (NSRange selection, direct _backedTextInputView access) - Fix setTextAndSelection using notifyDelegate:YES instead of NO, matching Paper behavior to prevent spurious delegate callbacks - Add ghost text cleanup in textInputDidChange delegate - Add RNTesterPlayground test case for verifying TextInput ref and commands (focus, blur, clear, setSelection, setGhostText) - Add debug logging in setLocalRef to diagnose ref null issue - Add test assertions for setSelection and setGhostText methods Co-Authored-By: Claude Opus 4.6 --- .../Components/TextInput/TextInput.js | 6 + .../TextInput/__tests__/TextInput-test.js | 2 + .../TextInput/RCTTextInputComponentView.mm | 130 +++++++++++++- .../TextInput/RCTTextInputNativeCommands.h | 20 +++ .../examples/Playground/RNTesterPlayground.js | 167 +++++++++++++++++- 5 files changed, 318 insertions(+), 7 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index e1e249acc3ed..0b79bad0fca9 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -467,6 +467,12 @@ function InternalTextInput(props: TextInputProps): React.Node { const setLocalRef = useCallback( (instance: HostInstance | null) => { + if (__DEV__) { + console.log( + '[TextInput] setLocalRef called with:', + instance != null ? 'instance' : 'null', + ); + } // $FlowExpectedError[incompatible-type] inputRef.current = instance; diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js index bf63d6957215..21e5575bf983 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-test.js @@ -65,6 +65,8 @@ jest.unmock('../TextInput'); expect(inputElement.isFocused).toBeInstanceOf(Function); // Would have prevented S168585 expect(inputElement.clear).toBeInstanceOf(Function); + expect(inputElement.setSelection).toBeInstanceOf(Function); + expect(inputElement.setGhostText).toBeInstanceOf(Function); // [macOS] // $FlowFixMe[method-unbinding] expect(inputElement.focus).toBeInstanceOf(jest.fn().constructor); // $FlowFixMe[method-unbinding] diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm index 129440f4db66..ba03ac0f6879 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm @@ -102,6 +102,10 @@ @implementation RCTTextInputComponentView { BOOL _hasInputAccessoryView; CGSize _previousContentSize; +#if TARGET_OS_OSX // [macOS + NSString *_ghostText; + NSInteger _ghostTextPosition; +#endif // macOS] } #pragma mark - UIView overrides @@ -477,6 +481,10 @@ - (void)prepareForRecycle _lastStringStateWasUpdatedWith = nil; _ignoreNextTextInputCall = NO; _didMoveToWindow = NO; +#if TARGET_OS_OSX // [macOS + _ghostText = nil; + _ghostTextPosition = 0; +#endif // macOS] [_backedTextInputView resignFirstResponder]; } @@ -580,6 +588,17 @@ - (void)textInputDidChange return; } +#if TARGET_OS_OSX // [macOS + if (_ghostText != nil) { + NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString:_backedTextInputView.attributedText strict:NO]; + if (attributedStringWithoutGhostText != nil && ![attributedStringWithoutGhostText isEqual:_backedTextInputView.attributedText]) { + _backedTextInputView.attributedText = attributedStringWithoutGhostText; + } + _ghostText = nil; + _ghostTextPosition = 0; + } +#endif // macOS] + if (_ignoreNextTextInputCall && [_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) { _ignoreNextTextInputCall = NO; return; @@ -768,6 +787,115 @@ - (void)scrollViewDidScroll:(RCTUIScrollView *)scrollView // [macOS] } } +#if TARGET_OS_OSX // [macOS +#pragma mark - Ghost Text + +- (NSDictionary *)ghostTextAttributes +{ + NSMutableDictionary *textAttributes = + [_backedTextInputView.defaultTextAttributes mutableCopy] ?: [NSMutableDictionary new]; + + [textAttributes setValue:_backedTextInputView.placeholderColor ?: [RCTPlatformColor placeholderTextColor] + forKey:NSForegroundColorAttributeName]; + + return textAttributes; +} + +- (void)setGhostText:(NSString *)ghostText +{ + NSRange selectedRange = [_backedTextInputView selectedTextRange]; + NSInteger selectionStart = selectedRange.location; + NSInteger selectionEnd = selectedRange.location + selectedRange.length; + NSString *newGhostText = ghostText.length > 0 ? ghostText : nil; + + if (selectionStart != selectionEnd) { + newGhostText = nil; + } + + if ((_ghostText == nil && newGhostText == nil) || [_ghostText isEqual:newGhostText]) { + return; + } + + if (_backedTextInputView.ghostTextChanging) { + // look out for nested callbacks -- this can happen for example when selection changes in response to + // attributed text changing. Such callbacks are initiated by Apple, or we could suppress this other ways. + return; + } + + _backedTextInputView.ghostTextChanging = YES; + + if (_ghostText != nil) { + // When setGhostText: is called after making a standard edit, the ghost text may already be gone + BOOL ghostTextMayAlreadyBeGone = newGhostText == nil; + NSAttributedString *attributedStringWithoutGhostText = [self removingGhostTextFromString:_backedTextInputView.attributedText strict:!ghostTextMayAlreadyBeGone]; + + if (attributedStringWithoutGhostText != nil) { + _backedTextInputView.attributedText = attributedStringWithoutGhostText; + [_backedTextInputView setSelectedTextRange:NSMakeRange(selectionStart, selectionEnd - selectionStart) notifyDelegate:NO]; + } + } + + _ghostText = [newGhostText copy]; + _ghostTextPosition = selectionStart; + + if (_ghostText != nil) { + NSMutableAttributedString *attributedString = [_backedTextInputView.attributedText mutableCopy]; + NSAttributedString *ghostAttributedString = [[NSAttributedString alloc] initWithString:_ghostText + attributes:self.ghostTextAttributes]; + + [attributedString insertAttributedString:ghostAttributedString atIndex:_ghostTextPosition]; + _backedTextInputView.attributedText = attributedString; + [_backedTextInputView setSelectedTextRange:NSMakeRange(_ghostTextPosition, 0) notifyDelegate:NO]; + } + + _backedTextInputView.ghostTextChanging = NO; +} + +/** + * Attempts to remove the ghost text from a provided string given our current state. + * + * If `strict` mode is enabled, this method assumes the ghost text exists exactly + * where we expect it to be. We assert and return `nil` if we don't find the expected ghost text. + * It's the responsibility of the caller to make sure the result isn't `nil`. + * + * If disabled, we allow for the possibility that the ghost text has already been removed, + * which can happen if a delegate callback is trying to remove ghost text after invoking `setAttributedText:`. + */ +- (NSAttributedString *)removingGhostTextFromString:(NSAttributedString *)string strict:(BOOL)strict +{ + if (_ghostText == nil) { + return string; + } + + NSRange ghostTextRange = NSMakeRange(_ghostTextPosition, _ghostText.length); + NSMutableAttributedString *attributedString = [string mutableCopy]; + + if ([attributedString length] < NSMaxRange(ghostTextRange)) { + if (strict) { + RCTAssert(false, @"Ghost text not fully present in text view text"); + return nil; + } else { + return string; + } + } + + NSString *actualGhostText = [[attributedString attributedSubstringFromRange:ghostTextRange] string]; + + if (![actualGhostText isEqual:_ghostText]) { + if (strict) { + RCTAssert(false, @"Ghost text does not match text view text"); + return nil; + } else { + return string; + } + } + + [attributedString deleteCharactersInRange:ghostTextRange]; + return attributedString; +} + +#endif // macOS] + #pragma mark - Native Commands - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args @@ -844,7 +972,7 @@ - (void)setTextAndSelection:(NSInteger)eventCount #else // [macOS NSInteger startPosition = MIN(start, end); NSInteger endPosition = MAX(start, end); - [_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:YES]; + [_backedTextInputView setSelectedTextRange:NSMakeRange(startPosition, endPosition - startPosition) notifyDelegate:NO]; #endif // macOS] _comingFromJS = NO; } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h index fe3376a573cc..196c6b8a3b6a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h @@ -18,6 +18,9 @@ NS_ASSUME_NONNULL_BEGIN value:(NSString *__nullable)value start:(NSInteger)start end:(NSInteger)end; +#if TARGET_OS_OSX // [macOS +- (void)setGhostText:(NSString *__nullable)ghostText; +#endif // macOS] @end RCT_EXTERN inline void @@ -96,6 +99,23 @@ RCTTextInputHandleCommand(id componentView, const NSSt return; } +#if TARGET_OS_OSX // [macOS + if ([commandName isEqualToString:@"setGhostText"]) { +#if RCT_DEBUG + if ([args count] != 1) { + RCTLogError( + @"%@ command %@ received %d arguments, expected %d.", @"TextInput", commandName, (int)[args count], 1); + return; + } +#endif + + NSObject *arg0 = args[0]; + NSString *value = [arg0 isKindOfClass:[NSNull class]] ? nil : (NSString *)arg0; + [componentView setGhostText:value]; + return; + } +#endif // macOS] + #if RCT_DEBUG RCTLogError(@"%@ received command %@, which is not a supported command.", @"TextInput", commandName); #endif diff --git a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js index d37d0d4a9154..2103b4f56455 100644 --- a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js +++ b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js @@ -12,15 +12,128 @@ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import RNTesterText from '../../components/RNTesterText'; import * as React from 'react'; -import {StyleSheet, View} from 'react-native'; +import { + Button, + Platform, + ScrollView, + StyleSheet, + TextInput, + View, +} from 'react-native'; function Playground() { + const textInputRef = React.useRef | null>( + null, + ); + const [refStatus, setRefStatus] = React.useState('pending...'); + const [log, setLog] = React.useState(''); + + React.useEffect(() => { + const status = + textInputRef.current != null ? 'REF OK' : 'REF IS NULL'; + setRefStatus(status); + }, []); + + const appendLog = React.useCallback((message: string) => { + setLog(prev => (prev ? prev + '\n' : '') + message); + }, []); + + const runCommand = React.useCallback( + (name: string, fn: (ref: React.ElementRef) => void) => { + try { + if (textInputRef.current == null) { + appendLog(`${name}: FAILED - ref is null`); + return; + } + fn(textInputRef.current); + appendLog(`${name}: OK`); + } catch (e: mixed) { + const message = e instanceof Error ? e.message : String(e); + appendLog(`${name}: ERROR - ${message}`); + } + }, + [appendLog], + ); + return ( - - - Edit "RNTesterPlayground.js" to change this file + + + TextInput Ref & Commands Test - + + + Ref status: {refStatus} + + + + + +