From 9634628c68dda41e7ee5d33358fcfb5c03cd0868 Mon Sep 17 00:00:00 2001 From: Yang Bai Date: Tue, 27 Oct 2026 18:52:02 -0700 Subject: [PATCH] Report keyboard show notifications with an empty frame as hide events (#59559) Summary: When a foldable iPhone is folded, unfolded or rotated, iOS can send a "keyboard will show" notification for a keyboard with zero height while no text field is focused. `RCTKeyboardObserver` passed it to JS as a show, so screens acted as if a keyboard were open: inputs stayed in their keyboard-up position and some pages went blank. Now a zero-height show with no focused text field is sent as a hide. Everything else is unchanged, including hardware keyboards on iPad, which send zero-height shows while a field is focused. Changelog: [iOS][Fixed] - Report zero-height keyboard show notifications with no focused text field as `keyboardWillHide`/`keyboardDidHide` Reviewed By: cipolleschi Differential Revision: D127272268 --- .../React/CoreModules/RCTKeyboardObserver.mm | 51 ++++++ .../CoreModules/RCTKeyboardObserverTests.m | 149 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 packages/react-native/React/Tests/CoreModules/RCTKeyboardObserverTests.m diff --git a/packages/react-native/React/CoreModules/RCTKeyboardObserver.mm b/packages/react-native/React/CoreModules/RCTKeyboardObserver.mm index ccaf9a84da89..ff846172b703 100644 --- a/packages/react-native/React/CoreModules/RCTKeyboardObserver.mm +++ b/packages/react-native/React/CoreModules/RCTKeyboardObserver.mm @@ -18,6 +18,31 @@ @interface RCTKeyboardObserver () @end +#if !TARGET_OS_TV +@interface UIResponder (RCTKeyboardObserver) +- (void)reactCollectFirstResponder:(NSMutableArray *)firstResponders; +@end + +@implementation UIResponder (RCTKeyboardObserver) + +- (void)reactCollectFirstResponder:(NSMutableArray *)firstResponders +{ + if (self.isFirstResponder) { + [firstResponders addObject:self]; + } +} + +@end + +// UIKit sends a nil-targeted action to the first responder, if there is one, and then up its responder chain. +static BOOL RCTHasFirstResponder() +{ + NSMutableArray *firstResponders = [NSMutableArray new]; + [RCTSharedApplication() sendAction:@selector(reactCollectFirstResponder:) to:nil from:firstResponders forEvent:nil]; + return firstResponders.count > 0; +} +#endif + @implementation RCTKeyboardObserver RCT_EXPORT_MODULE() @@ -69,8 +94,34 @@ -(void)EVENT : (NSNotification *)notification [self sendEventWithName:@ #EVENT body:RCTParseKeyboardNotification(notification)]; \ } +#if !TARGET_OS_TV +// UIKit can post a show notification with an empty end frame while no view is first responder, for example when the +// window is resized. No keyboard can be shown then, so it is sent as the matching hide. +- (void)keyboardWillShow:(NSNotification *)notification +{ + if (self.callableJSModules == nil) { + return; + } + BOOL isShown = + !CGRectIsEmpty([notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]) || RCTHasFirstResponder(); + [self sendEventWithName:isShown ? @"keyboardWillShow" : @"keyboardWillHide" + body:RCTParseKeyboardNotification(notification)]; +} + +- (void)keyboardDidShow:(NSNotification *)notification +{ + if (self.callableJSModules == nil) { + return; + } + BOOL isShown = + !CGRectIsEmpty([notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]) || RCTHasFirstResponder(); + [self sendEventWithName:isShown ? @"keyboardDidShow" : @"keyboardDidHide" + body:RCTParseKeyboardNotification(notification)]; +} +#else IMPLEMENT_KEYBOARD_HANDLER(keyboardWillShow) IMPLEMENT_KEYBOARD_HANDLER(keyboardDidShow) +#endif IMPLEMENT_KEYBOARD_HANDLER(keyboardWillHide) IMPLEMENT_KEYBOARD_HANDLER(keyboardDidHide) IMPLEMENT_KEYBOARD_HANDLER(keyboardWillChangeFrame) diff --git a/packages/react-native/React/Tests/CoreModules/RCTKeyboardObserverTests.m b/packages/react-native/React/Tests/CoreModules/RCTKeyboardObserverTests.m new file mode 100644 index 000000000000..1e9c210f48c4 --- /dev/null +++ b/packages/react-native/React/Tests/CoreModules/RCTKeyboardObserverTests.m @@ -0,0 +1,149 @@ +/* + * 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 + +#if !TARGET_OS_TV + +@interface RCTKeyboardObserver (Tests) +- (void)keyboardWillShow:(NSNotification *)notification; +- (void)keyboardDidShow:(NSNotification *)notification; +- (void)keyboardWillHide:(NSNotification *)notification; +- (void)keyboardDidHide:(NSNotification *)notification; +- (void)keyboardWillChangeFrame:(NSNotification *)notification; +- (void)keyboardDidChangeFrame:(NSNotification *)notification; +@end + +@interface RCTRecordingKeyboardObserver : RCTKeyboardObserver +@property (nonatomic, readonly) NSMutableArray *sentEventNames; +@end + +@implementation RCTRecordingKeyboardObserver + +- (instancetype)init +{ + if (self = [super init]) { + _sentEventNames = [NSMutableArray new]; + } + return self; +} + +- (void)sendEventWithName:(NSString *)name body:(id)body +{ + [_sentEventNames addObject:name]; +} + +@end + +static NSNotification *keyboardNotification(NSNotificationName name, CGRect endFrame) +{ + return [NSNotification notificationWithName:name + object:nil + userInfo:@{ + UIKeyboardFrameBeginUserInfoKey : [NSValue valueWithCGRect:CGRectZero], + UIKeyboardFrameEndUserInfoKey : [NSValue valueWithCGRect:endFrame], + }]; +} + +@interface RCTKeyboardObserverTests : XCTestCase +@end + +@implementation RCTKeyboardObserverTests { + RCTCallableJSModules *_callableJSModules; + RCTRecordingKeyboardObserver *_observer; +} + +- (void)setUp +{ + [super setUp]; + _callableJSModules = [RCTCallableJSModules new]; + _observer = [RCTRecordingKeyboardObserver new]; + _observer.callableJSModules = _callableJSModules; +} + +- (void)testShowWithKeyboardFrameIsSentAsShow +{ + CGRect keyboardFrame = CGRectMake(0, 389, 466, 289); + [_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, keyboardFrame)]; + [_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, keyboardFrame)]; + + XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ])); +} + +- (void)testShowWithKeyboardFrameAtTopIsSentAsShow +{ + CGRect keyboardFrameAtTop = CGRectMake(0, 0, 466, 289); + [_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, keyboardFrameAtTop)]; + [_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, keyboardFrameAtTop)]; + + XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ])); +} + +- (void)testShowWithEmptyFrameIsSentAsHide +{ + CGRect zeroHeightFrame = CGRectMake(0, 951, 669, 0); + [_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, zeroHeightFrame)]; + [_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, zeroHeightFrame)]; + + CGRect zeroWidthFrame = CGRectMake(273, 0, 0, 264); + [_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, zeroWidthFrame)]; + [_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, zeroWidthFrame)]; + + XCTAssertEqualObjects( + _observer.sentEventNames, + (@[ @"keyboardWillHide", @"keyboardDidHide", @"keyboardWillHide", @"keyboardDidHide" ])); +} + +- (void)testShowWithEmptyFrameAndFirstResponderIsSentAsShow +{ + UIWindowScene *windowScene = (UIWindowScene *)UIApplication.sharedApplication.connectedScenes.anyObject; + if (windowScene == nil) { + XCTFail(@"The test host has no window scene"); + return; + } + UIWindow *window = [[UIWindow alloc] initWithWindowScene:windowScene]; + UITextField *textField = [[UITextField alloc] initWithFrame:CGRectMake(0, 0, 200, 40)]; + [window addSubview:textField]; + [window makeKeyAndVisible]; + XCTAssertTrue([textField becomeFirstResponder]); + + CGRect emptyFrame = CGRectMake(0, 1180, 0, 0); + [_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, emptyFrame)]; + [_observer keyboardDidShow:keyboardNotification(UIKeyboardDidShowNotification, emptyFrame)]; + + [textField resignFirstResponder]; + window.hidden = YES; + XCTAssertEqualObjects(_observer.sentEventNames, (@[ @"keyboardWillShow", @"keyboardDidShow" ])); +} + +- (void)testHideAndFrameChangeAreUnchanged +{ + CGRect emptyFrame = CGRectMake(0, 678, 466, 0); + [_observer keyboardWillHide:keyboardNotification(UIKeyboardWillHideNotification, emptyFrame)]; + [_observer keyboardDidHide:keyboardNotification(UIKeyboardDidHideNotification, emptyFrame)]; + [_observer keyboardWillChangeFrame:keyboardNotification(UIKeyboardWillChangeFrameNotification, emptyFrame)]; + [_observer keyboardDidChangeFrame:keyboardNotification(UIKeyboardDidChangeFrameNotification, emptyFrame)]; + + XCTAssertEqualObjects( + _observer.sentEventNames, + (@[ @"keyboardWillHide", @"keyboardDidHide", @"keyboardWillChangeFrame", @"keyboardDidChangeFrame" ])); +} + +- (void)testNothingIsSentWithoutJSModules +{ + _observer.callableJSModules = nil; + [_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, CGRectMake(0, 951, 669, 0))]; + [_observer keyboardWillShow:keyboardNotification(UIKeyboardWillShowNotification, CGRectMake(0, 389, 466, 289))]; + + XCTAssertEqual(_observer.sentEventNames.count, 0u); +} + +@end + +#endif