From 3b1b5156c320d00ed349a246480cb899a09fd526 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 7 Apr 2026 17:14:43 -0700 Subject: [PATCH] feat(ios): Move dev menu keyboard shortcuts from RCTKeyCommands swizzling to UIKeyCommand on RCTSurfaceHostingView Replace global method swizzling (via RCTKeyCommands) with per-surface UIKeyCommand registration for Cmd+D (toggle dev menu) and Cmd+I (toggle element inspector). This avoids private API usage that can trigger App Store review flags and scopes shortcuts to individual RN surfaces instead of registering them globally. Co-Authored-By: Claude Opus 4.6 --- .../Libraries/AppDelegate/RCTAppSetupUtils.mm | 10 --- .../AppDelegate/RCTRootViewFactory.mm | 20 +++++ .../RCTSurfaceHostingView.h | 10 +++ .../RCTSurfaceHostingView.mm | 35 ++++++++ .../React/CoreModules/RCTDevMenu.h | 17 +++- .../React/CoreModules/RCTDevMenu.mm | 89 ++++--------------- .../platform/ios/ReactCommon/RCTInstance.mm | 10 --- 7 files changed, 96 insertions(+), 95 deletions(-) diff --git a/packages/react-native/Libraries/AppDelegate/RCTAppSetupUtils.mm b/packages/react-native/Libraries/AppDelegate/RCTAppSetupUtils.mm index fabfff326b85..41bd41f44c4b 100644 --- a/packages/react-native/Libraries/AppDelegate/RCTAppSetupUtils.mm +++ b/packages/react-native/Libraries/AppDelegate/RCTAppSetupUtils.mm @@ -124,16 +124,6 @@ void RCTAppSetupPrepareApp(UIApplication *application, BOOL turboModuleEnabled) // Necessary to allow NativeModules to lookup TurboModules [bridge setRCTTurboModuleRegistry:turboModuleManager]; -#if RCT_DEV - /** - * Instantiating DevMenu has the side-effect of registering - * shortcuts for CMD + d, CMD + i, and CMD + n via RCTDevMenu. - * Therefore, when TurboModules are enabled, we must manually create this - * NativeModule. - */ - [turboModuleManager moduleForName:"RCTDevMenu"]; -#endif // end RCT_DEV - auto runtimeInstallerLambda = [turboModuleManager, bridge, runtimeScheduler](facebook::jsi::Runtime &runtime) { if (!bridge || !turboModuleManager) { return; diff --git a/packages/react-native/Libraries/AppDelegate/RCTRootViewFactory.mm b/packages/react-native/Libraries/AppDelegate/RCTRootViewFactory.mm index 4eaecb0b46ef..a68b448dfd4b 100644 --- a/packages/react-native/Libraries/AppDelegate/RCTRootViewFactory.mm +++ b/packages/react-native/Libraries/AppDelegate/RCTRootViewFactory.mm @@ -34,6 +34,10 @@ #import #import +#if RCT_DEV_MENU +#import +#endif // RCT_DEV_MENU + @implementation RCTRootViewFactoryConfiguration - (instancetype)initWithBundleURL:(NSURL *)bundleURL newArchEnabled:(BOOL)newArchEnabled @@ -189,6 +193,12 @@ - (UIView *)viewWithModuleName:(NSString *)moduleName RCTSurfaceHostingProxyRootView *surfaceHostingProxyRootView = [[RCTSurfaceHostingProxyRootView alloc] initWithSurface:surface]; +#if RCT_DEV_MENU + RCTDevMenu *devMenu = [self.reactHost.moduleRegistry moduleForClass:[RCTDevMenu class]]; + if (devMenu) { + surfaceHostingProxyRootView.devMenu = devMenu; + } +#endif // RCT_DEV_MENU #if TARGET_OS_TV surfaceHostingProxyRootView.backgroundColor = [UIColor clearColor]; @@ -216,6 +226,16 @@ - (UIView *)createRootViewWithBridge:(RCTBridge *)bridge #else rootView.backgroundColor = [UIColor blackColor]; #endif + +#if RCT_DEV_MENU + if ([rootView isKindOfClass:[RCTSurfaceHostingView class]]) { + RCTDevMenu *devMenu = [bridge moduleForClass:[RCTDevMenu class]]; + if (devMenu) { + [(RCTSurfaceHostingView *)rootView setDevMenu:devMenu]; + } + } +#endif // RCT_DEV_MENU + return rootView; } diff --git a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.h b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.h index 2cf639585c8d..3adcb60e7fd6 100644 --- a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.h +++ b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.h @@ -12,6 +12,8 @@ #import #import +@class RCTDevMenu; + typedef UIView *_Nullable (^RCTSurfaceHostingViewActivityIndicatorViewFactory)(void); NS_ASSUME_NONNULL_BEGIN @@ -60,6 +62,14 @@ NS_ASSUME_NONNULL_BEGIN * @param disabled if `YES`, the auto-hide is disabled. Otherwise the loading view will be hidden automatically */ - (void)disableActivityIndicatorAutoHide:(BOOL)disabled; + +#if RCT_DEV_MENU +/** + * Dev menu for key command access (Cmd+D, Cmd+I). + */ +@property (nonatomic, strong, nullable) RCTDevMenu *devMenu; +#endif + @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm index 511e398a8bb7..8b2c3a7a75ef 100644 --- a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm +++ b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm @@ -13,6 +13,10 @@ #import "RCTSurfaceView.h" #import "RCTUtils.h" +#if RCT_DEV_MENU +#import "RCTDevMenu.h" +#endif // RCT_DEV_MENU + @interface RCTSurfaceHostingView () @property (nonatomic, assign) BOOL isActivityIndicatorViewVisible; @@ -249,4 +253,35 @@ - (void)surface:(__unused RCTSurface *)surface didChangeIntrinsicSize:(__unused }); } +#pragma mark - Dev Menu + +#if RCT_DEV_MENU +- (BOOL)canBecomeFirstResponder +{ + return YES; +} + +- (NSArray *)keyCommands +{ + return @[ + [UIKeyCommand keyCommandWithInput:@"d" + modifierFlags:UIKeyModifierCommand + action:@selector(toggleDevMenu)], + [UIKeyCommand keyCommandWithInput:@"i" + modifierFlags:UIKeyModifierCommand + action:@selector(toggleElementInspector)], + ]; +} + +- (void)toggleDevMenu +{ + [_devMenu toggle]; +} + +- (void)toggleElementInspector +{ + [_devMenu toggleElementInspector]; +} +#endif // RCT_DEV_MENU + @end diff --git a/packages/react-native/React/CoreModules/RCTDevMenu.h b/packages/react-native/React/CoreModules/RCTDevMenu.h index 976cb8c517b5..4cda564ca73a 100644 --- a/packages/react-native/React/CoreModules/RCTDevMenu.h +++ b/packages/react-native/React/CoreModules/RCTDevMenu.h @@ -57,7 +57,7 @@ RCT_EXTERN NSString *const RCTShowDevMenuNotification; /** * Whether the hotkeys that toggles the developer menu is enabled. */ -@property (nonatomic, assign) BOOL hotkeysEnabled; +@property (nonatomic, assign) BOOL hotkeysEnabled DEPRECATED_ATTRIBUTE; /** * Whether the developer menu is enabled. @@ -84,6 +84,21 @@ RCT_EXTERN NSString *const RCTShowDevMenuNotification; */ - (void)show; +/** + * Manually toggle the dev menu. + */ +- (void)toggle; + +/** + * Toggle the element inspector (called by key command). + */ +- (void)toggleElementInspector; + +/** + * Reload from key command (called by key command). + */ +- (void)reloadFromKeyCommand; + /** * Deprecated, use `RCTReloadCommand` instead. */ diff --git a/packages/react-native/React/CoreModules/RCTDevMenu.mm b/packages/react-native/React/CoreModules/RCTDevMenu.mm index 890c1596df74..52c602f55898 100644 --- a/packages/react-native/React/CoreModules/RCTDevMenu.mm +++ b/packages/react-native/React/CoreModules/RCTDevMenu.mm @@ -12,7 +12,6 @@ #import #import #import -#import #import #import #import @@ -150,74 +149,10 @@ - (instancetype)init _keyboardShortcutsEnabled = true; _devMenuEnabled = true; - [self registerHotkeys]; } return self; } -- (void)registerHotkeys -{ -#if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST - RCTKeyCommands *commands = [RCTKeyCommands sharedInstance]; - __weak __typeof(self) weakSelf = self; - - // Toggle debug menu - [commands registerKeyCommandWithInput:@"d" - modifierFlags:UIKeyModifierCommand - action:^(__unused UIKeyCommand *command) { - [weakSelf toggle]; - }]; - - // Toggle element inspector - [commands registerKeyCommandWithInput:@"i" - modifierFlags:UIKeyModifierCommand - action:^(__unused UIKeyCommand *command) { - [(RCTDevSettings *)[weakSelf.moduleRegistry moduleForName:"DevSettings"] - toggleElementInspector]; - }]; -#endif -} - -- (void)unregisterHotkeys -{ -#if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST - RCTKeyCommands *commands = [RCTKeyCommands sharedInstance]; - - [commands unregisterKeyCommandWithInput:@"d" modifierFlags:UIKeyModifierCommand]; - [commands unregisterKeyCommandWithInput:@"i" modifierFlags:UIKeyModifierCommand]; -#endif -} - -- (BOOL)isHotkeysRegistered -{ -#if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST - RCTKeyCommands *commands = [RCTKeyCommands sharedInstance]; - - return [commands isKeyCommandRegisteredForInput:@"d" modifierFlags:UIKeyModifierCommand] && - [commands isKeyCommandRegisteredForInput:@"i" modifierFlags:UIKeyModifierCommand]; -#else - return NO; -#endif -} - -- (BOOL)isReloadCommandRegistered -{ -#if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST - RCTKeyCommands *commands = [RCTKeyCommands sharedInstance]; - return [commands isKeyCommandRegisteredForInput:@"r" modifierFlags:UIKeyModifierCommand]; -#else - return NO; -#endif -} - -- (void)unregisterReloadCommand -{ -#if TARGET_OS_SIMULATOR || TARGET_OS_MACCATALYST - RCTKeyCommands *commands = [RCTKeyCommands sharedInstance]; - [commands unregisterKeyCommandWithInput:@"r" modifierFlags:UIKeyModifierCommand]; -#endif -} - - (dispatch_queue_t)methodQueue { return dispatch_get_main_queue(); @@ -525,23 +460,29 @@ - (BOOL)hotLoadingEnabled - (void)setHotkeysEnabled:(BOOL)enabled { - if (enabled) { - [self registerHotkeys]; - } else { - [self unregisterHotkeys]; - } + // Deprecated: hotkeys are now managed via UIKeyCommand on RCTSurfaceHostingView } - (BOOL)hotkeysEnabled { - return [self isHotkeysRegistered]; + // Deprecated: hotkeys are now managed via UIKeyCommand on RCTSurfaceHostingView + return NO; } - (void)disableReloadCommand { - if ([self isReloadCommandRegistered]) { - [self unregisterReloadCommand]; - } + // Deprecated: reload command is now managed via UIKeyCommand on RCTSurfaceHostingView +} + +- (void)toggleElementInspector +{ + RCTDevSettings *devSettings = [_moduleRegistry moduleForName:"DevSettings"]; + [devSettings toggleElementInspector]; +} + +- (void)reloadFromKeyCommand +{ + RCTTriggerReloadCommandListeners(@"Dev menu key command"); } - (std::shared_ptr)getTurboModule: diff --git a/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm b/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm index d065b1b7943d..32c627ffbb51 100644 --- a/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm +++ b/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm @@ -358,16 +358,6 @@ - (void)_start jsInvoker:jsCallInvoker devMenuConfigurationDecorator:_devMenuConfigurationDecorator]; -#if RCT_DEV - /** - * Instantiating DevMenu has the side-effect of registering - * shortcuts for CMD + d, CMD + i, and CMD + n via RCTDevMenu. - * Therefore, when TurboModules are enabled, we must manually create this - * NativeModule. - */ - [_turboModuleManager moduleForName:"RCTDevMenu"]; -#endif // end RCT_DEV - // Initialize RCTModuleRegistry so that TurboModules can require other TurboModules. [_bridgeModuleDecorator.moduleRegistry setTurboModuleRegistry:_turboModuleManager];