Export the DevSettings module, add `addMenuItem` method (#25848)

Summary:
I wanted to configure the RN dev menu without having to write native code. This is pretty useful in a greenfield app since it avoids having to write a custom native module for both platforms (and might enable the feature for expo too).

This ended up a bit more involved than planned since callbacks can only be called once. I needed to convert the `DevSettings` module to a `NativeEventEmitter` and use events when buttons are clicked. This means creating a JS wrapper for it. Currently it does not export all methods, they can be added in follow ups as needed.

## Changelog

[General] [Added] - Export the DevSettings module, add `addMenuItem` method
Pull Request resolved: https://github.com/facebook/react-native/pull/25848

Test Plan:
Tested in an app using the following code.

```js
if (__DEV__) {
 DevSettings.addMenuItem('Show Dev Screen', () => {
    dispatchNavigationAction(
      NavigationActions.navigate({
        routeName: 'dev',
      }),
    );
  });
}
```

Added an example in RN tester

![devmenu](https://user-images.githubusercontent.com/2677334/62000297-71624680-b0a1-11e9-8403-bc95c4747f0c.gif)

Differential Revision: D17394916

Pulled By: cpojer

fbshipit-source-id: f9d2c548b09821c594189d1436a27b97cf5a5737
This commit is contained in:
Janic Duplessis 2019-09-17 06:36:17 -07:00 коммит произвёл Facebook Github Bot
Родитель 15343863b0
Коммит cc068b0551
15 изменённых файлов: 190 добавлений и 22 удалений

Просмотреть файл

@ -801,6 +801,10 @@ namespace facebook {
return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "toggleElementInspector", @selector(toggleElementInspector), args, count);
}
static facebook::jsi::Value __hostFunction_NativeDevSettingsSpecJSI_addMenuItem(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "addMenuItem", @selector(addMenuItem:), args, count);
}
static facebook::jsi::Value __hostFunction_NativeDevSettingsSpecJSI_setIsShakeToShowDevMenuEnabled(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "setIsShakeToShowDevMenuEnabled", @selector(setIsShakeToShowDevMenuEnabled:), args, count);
}
@ -824,6 +828,9 @@ namespace facebook {
methodMap_["toggleElementInspector"] = MethodMetadata {0, __hostFunction_NativeDevSettingsSpecJSI_toggleElementInspector};
methodMap_["addMenuItem"] = MethodMetadata {1, __hostFunction_NativeDevSettingsSpecJSI_addMenuItem};
methodMap_["setIsShakeToShowDevMenuEnabled"] = MethodMetadata {1, __hostFunction_NativeDevSettingsSpecJSI_setIsShakeToShowDevMenuEnabled};

Просмотреть файл

@ -743,6 +743,7 @@ namespace facebook {
- (void)setIsDebuggingRemotely:(BOOL)isDebuggingRemotelyEnabled;
- (void)setProfilingEnabled:(BOOL)isProfilingEnabled;
- (void)toggleElementInspector;
- (void)addMenuItem:(NSString *)title;
- (void)setIsShakeToShowDevMenuEnabled:(BOOL)enabled;
@end

Просмотреть файл

@ -19,6 +19,7 @@ export interface Spec extends TurboModule {
+setIsDebuggingRemotely: (isDebuggingRemotelyEnabled: boolean) => void;
+setProfilingEnabled: (isProfilingEnabled: boolean) => void;
+toggleElementInspector: () => void;
+addMenuItem: (title: string) => void;
// iOS only.
+setIsShakeToShowDevMenuEnabled: (enabled: boolean) => void;

Просмотреть файл

@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* @format
*/
import NativeDevSettings from '../NativeModules/specs/NativeDevSettings';
import NativeEventEmitter from '../EventEmitter/NativeEventEmitter';
class DevSettings extends NativeEventEmitter {
_menuItems: Map<string, () => mixed>;
constructor() {
super(NativeDevSettings);
this._menuItems = new Map();
}
addMenuItem(title: string, handler: () => mixed) {
// Make sure items are not added multiple times. This can
// happen when hot reloading the module that registers the
// menu items. The title is used as the id which means we
// don't support multiple items with the same name.
const oldHandler = this._menuItems.get(title);
if (oldHandler != null) {
this.removeListener('didPressMenuItem', oldHandler);
} else {
NativeDevSettings.addMenuItem(title);
}
this._menuItems.set(title, handler);
this.addListener('didPressMenuItem', event => {
if (event.title === title) {
handler();
}
});
}
reload() {
NativeDevSettings.reload();
}
// TODO: Add other dev setting methods exposed by the native module.
}
// Avoid including the full `NativeDevSettings` class in prod.
class NoopDevSettings {
addMenuItem(title: string, handler: () => mixed) {}
reload() {}
}
module.exports = __DEV__ ? new DevSettings() : new NoopDevSettings();

Просмотреть файл

@ -0,0 +1,47 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
'use strict';
import * as React from 'react';
import {Alert, Button, DevSettings} from 'react-native';
exports.title = 'DevSettings';
exports.description = 'Customize the development settings';
exports.examples = [
{
title: 'Add dev menu item',
render(): React.Element<any> {
return (
<Button
title="Add"
onPress={() => {
DevSettings.addMenuItem('Show Secret Dev Screen', () => {
Alert.alert('Showing secret dev screen!');
});
}}
/>
);
},
},
{
title: 'Reload the app',
render(): React.Element<any> {
return (
<Button
title="Reload"
onPress={() => {
DevSettings.reload();
}}
/>
);
},
},
];

Просмотреть файл

@ -156,6 +156,10 @@ const APIExamples: Array<RNTesterExample> = [
key: 'DatePickerAndroidExample',
module: require('../examples/DatePicker/DatePickerAndroidExample'),
},
{
key: 'DevSettings',
module: require('../examples/DevSettings/DevSettingsExample'),
},
{
key: 'Dimensions',
module: require('../examples/Dimensions/DimensionsExample'),

Просмотреть файл

@ -235,6 +235,10 @@ const APIExamples: Array<RNTesterExample> = [
module: require('../examples/Crash/CrashExample'),
supportsTVOS: false,
},
{
key: 'DevSettings',
module: require('../examples/DevSettings/DevSettingsExample'),
},
{
key: 'Dimensions',
module: require('../examples/Dimensions/DimensionsExample'),

Просмотреть файл

@ -7,6 +7,7 @@
#import <React/RCTBridge.h>
#import <React/RCTDefines.h>
#import <React/RCTEventEmitter.h>
@protocol RCTPackagerClientMethod;
@ -29,7 +30,7 @@
@end
@interface RCTDevSettings : NSObject
@interface RCTDevSettings : RCTEventEmitter
- (instancetype)initWithDataSource:(id<RCTDevSettingsDataSource>)dataSource;

Просмотреть файл

@ -16,6 +16,8 @@
#import "RCTProfile.h"
#import "RCTUtils.h"
#import <React/RCTDevMenu.h>
static NSString *const kRCTDevSettingProfilingEnabled = @"profilingEnabled";
static NSString *const kRCTDevSettingHotLoadingEnabled = @"hotLoadingEnabled";
static NSString *const kRCTDevSettingIsInspectorShown = @"showInspector";
@ -111,8 +113,6 @@ static NSString *const kRCTDevSettingsUserDefaultsKey = @"RCTDevMenu";
@implementation RCTDevSettings
@synthesize bridge = _bridge;
RCT_EXPORT_MODULE()
+ (BOOL)requiresMainQueueSetup
@ -152,8 +152,7 @@ RCT_EXPORT_MODULE()
- (void)setBridge:(RCTBridge *)bridge
{
RCTAssert(_bridge == nil, @"RCTDevSettings module should not be reused");
_bridge = bridge;
[super setBridge:bridge];
#if ENABLE_PACKAGER_CONNECTION
RCTBridge *__weak weakBridge = bridge;
@ -197,6 +196,11 @@ RCT_EXPORT_MODULE()
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (NSArray<NSString *> *)supportedEvents
{
return @[@"didPressMenuItem"];
}
- (void)_updateSettingWithValue:(id)value forKey:(NSString *)key
{
[_dataSource updateSettingWithValue:value forKey:key];
@ -210,7 +214,7 @@ RCT_EXPORT_MODULE()
- (BOOL)isNuclideDebuggingAvailable
{
#if RCT_ENABLE_INSPECTOR
return _bridge.isInspectable;
return self.bridge.isInspectable;
#else
return false;
#endif // RCT_ENABLE_INSPECTOR
@ -227,12 +231,12 @@ RCT_EXPORT_MODULE()
- (BOOL)isHotLoadingAvailable
{
return _bridge.bundleURL && !_bridge.bundleURL.fileURL; // Only works when running from server
return self.bridge.bundleURL && !self.bridge.bundleURL.fileURL; // Only works when running from server
}
RCT_EXPORT_METHOD(reload)
{
[_bridge reload];
[self.bridge reload];
}
RCT_EXPORT_METHOD(setIsShakeToShowDevMenuEnabled : (BOOL)enabled)
@ -285,10 +289,10 @@ RCT_EXPORT_METHOD(setProfilingEnabled : (BOOL)enabled)
BOOL enabled = self.isProfilingEnabled;
if (self.isHotLoadingAvailable && enabled != RCTProfileIsProfiling()) {
if (enabled) {
[_bridge startProfiling];
[self.bridge startProfiling];
} else {
[_bridge stopProfiling:^(NSData *logData) {
RCTProfileSendResult(self->_bridge, @"systrace", logData);
[self.bridge stopProfiling:^(NSData *logData) {
RCTProfileSendResult(self.bridge, @"systrace", logData);
}];
}
}
@ -302,9 +306,9 @@ RCT_EXPORT_METHOD(setHotLoadingEnabled : (BOOL)enabled)
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if (enabled) {
[_bridge enqueueJSCall:@"HMRClient" method:@"enable" args:@[] completion:NULL];
[self.bridge enqueueJSCall:@"HMRClient" method:@"enable" args:@[] completion:NULL];
} else {
[_bridge enqueueJSCall:@"HMRClient" method:@"disable" args:@[] completion:NULL];
[self.bridge enqueueJSCall:@"HMRClient" method:@"disable" args:@[] completion:NULL];
}
#pragma clang diagnostic pop
}
@ -329,6 +333,14 @@ RCT_EXPORT_METHOD(toggleElementInspector)
}
}
RCT_EXPORT_METHOD(addMenuItem:(NSString *)title)
{
__weak __typeof(self) weakSelf = self;
[self.bridge.devMenu addItem:[RCTDevMenuItem buttonItemWithTitle:title handler:^{
[weakSelf sendEventWithName:@"didPressMenuItem" body:@{@"title": title}];
}]];
}
- (BOOL)isElementInspectorShown
{
return [[self settingForKey:kRCTDevSettingIsInspectorShown] boolValue];
@ -347,17 +359,17 @@ RCT_EXPORT_METHOD(toggleElementInspector)
- (void)setExecutorClass:(Class)executorClass
{
_executorClass = executorClass;
if (_bridge.executorClass != executorClass) {
if (self.bridge.executorClass != executorClass) {
// TODO (6929129): we can remove this special case test once we have better
// support for custom executors in the dev menu. But right now this is
// needed to prevent overriding a custom executor with the default if a
// custom executor has been set directly on the bridge
if (executorClass == Nil && _bridge.executorClass != objc_lookUpClass("RCTWebSocketExecutor")) {
if (executorClass == Nil && self.bridge.executorClass != objc_lookUpClass("RCTWebSocketExecutor")) {
return;
}
_bridge.executorClass = executorClass;
[_bridge reload];
self.bridge.executorClass = executorClass;
[self.bridge reload];
}
}
@ -386,7 +398,7 @@ RCT_EXPORT_METHOD(toggleElementInspector)
- (void)jsLoaded:(NSNotification *)notification
{
if (notification.userInfo[@"bridge"] != _bridge) {
if (notification.userInfo[@"bridge"] != self.bridge) {
return;
}

Просмотреть файл

@ -52,4 +52,7 @@ public class ReactSettingsForTests implements DeveloperSettings {
public boolean isStartSamplingProfilerOnInit() {
return false;
}
@Override
public void addMenuItem(String title) {}
}

Просмотреть файл

@ -139,7 +139,7 @@ import java.util.Map;
case DeviceEventManagerModule.NAME:
return new DeviceEventManagerModule(reactContext, mHardwareBackBtnHandler);
case DevSettingsModule.NAME:
return new DevSettingsModule(mReactInstanceManager.getDevSupportManager());
return new DevSettingsModule(reactContext, mReactInstanceManager.getDevSupportManager());
case ExceptionsManagerModule.NAME:
return new ExceptionsManagerModule(mReactInstanceManager.getDevSupportManager());
case HeadlessJsTaskSupportModule.NAME:

Просмотреть файл

@ -123,6 +123,11 @@ public class DevInternalSettings
return mPreferences.getBoolean(PREFS_START_SAMPLING_PROFILER_ON_INIT, false);
}
@Override
public void addMenuItem(String title) {
// Not supported.
}
public interface Listener {
void onInternalSettingsChanged();
}

Просмотреть файл

@ -6,23 +6,31 @@
*/
package com.facebook.react.modules.debug;
import com.facebook.react.bridge.BaseJavaModule;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.devsupport.interfaces.DevOptionHandler;
import com.facebook.react.devsupport.interfaces.DevSupportManager;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter;
/**
* Module that exposes the URL to the source code map (used for exception stack trace parsing) to JS
*/
@ReactModule(name = DevSettingsModule.NAME)
public class DevSettingsModule extends BaseJavaModule {
public class DevSettingsModule extends ReactContextBaseJavaModule {
public static final String NAME = "DevSettings";
private final DevSupportManager mDevSupportManager;
public DevSettingsModule(DevSupportManager devSupportManager) {
public DevSettingsModule(
ReactApplicationContext reactContext, DevSupportManager devSupportManager) {
super(reactContext);
mDevSupportManager = devSupportManager;
}
@ -63,4 +71,20 @@ public class DevSettingsModule extends BaseJavaModule {
public void toggleElementInspector() {
mDevSupportManager.toggleElementInspector();
}
@ReactMethod
public void addMenuItem(final String title) {
mDevSupportManager.addCustomDevOption(
title,
new DevOptionHandler() {
@Override
public void onOptionSelected() {
WritableMap data = Arguments.createMap();
data.putString("title", title);
getReactApplicationContext()
.getJSModule(RCTDeviceEventEmitter.class)
.emit("didPressMenuItem", data);
}
});
}
}

Просмотреть файл

@ -35,4 +35,7 @@ public interface DeveloperSettings {
/** @return Whether Start Sampling Profiler on App Start is enabled. */
boolean isStartSamplingProfilerOnInit();
/** Add an item to the dev menu. */
void addMenuItem(String title);
}

Просмотреть файл

@ -56,6 +56,7 @@ import typeof BackHandler from './Libraries/Utilities/BackHandler';
import typeof Clipboard from './Libraries/Components/Clipboard/Clipboard';
import typeof DatePickerAndroid from './Libraries/Components/DatePickerAndroid/DatePickerAndroid';
import typeof DeviceInfo from './Libraries/Utilities/DeviceInfo';
import typeof DevSettings from './Libraries/Utilities/DevSettings';
import typeof Dimensions from './Libraries/Utilities/Dimensions';
import typeof Easing from './Libraries/Animated/src/Easing';
import typeof ReactNative from './Libraries/Renderer/shims/ReactNative';
@ -291,6 +292,9 @@ module.exports = {
get DeviceInfo(): DeviceInfo {
return require('./Libraries/Utilities/DeviceInfo');
},
get DevSettings(): DevSettings {
return require('./Libraries/Utilities/DevSettings');
},
get Dimensions(): Dimensions {
return require('./Libraries/Utilities/Dimensions');
},