Skip to content

Commit

Permalink
[cli][dev-menu][go] add react devtools (#21462)
Browse files Browse the repository at this point in the history
close ENG-7468
close ENG-7469

- [cli] add websocket proxy to forward react-devtools events.
- [cli] add static page for react-devtools frontend. since react-devtools only ships commonjs format, this pr tries to use jspm to support it on browsers.
- [dev-menu][go] listen `reconnectDevTools` metro websocket event and send `RCTDevMenuShown` to js for app to reconnect devtools websocket

manual test only. please let me know if there's any proper points to add unit tests

(cherry picked from commit fd05555)
  • Loading branch information
Kudo committed Apr 10, 2023
1 parent b426831 commit 68825a6
Show file tree
Hide file tree
Showing 15 changed files with 344 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,22 @@ abstract class ReactNativeActivity :
}
}

/**
* Emits events to `RCTNativeAppEventEmitter`
*/
fun emitRCTNativeAppEvent(eventName: String, eventArgs: Map<String, String>?) {
try {
val nativeAppEventEmitter =
RNObject("com.facebook.react.modules.core.RCTNativeAppEventEmitter")
nativeAppEventEmitter.loadVersion(detachSdkVersion!!)
val emitter = reactInstanceManager.callRecursive("getCurrentReactContext")!!
.callRecursive("getJSModule", nativeAppEventEmitter.rnClass())
emitter?.call("emit", eventName, eventArgs)
} catch (e: Throwable) {
EXL.e(TAG, e)
}
}

// for getting global permission
override fun checkSelfPermission(permission: String): Int {
return super.checkPermission(permission, Process.myPid(), Process.myUid())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ object VersionedUtils {
}
}

private fun reconnectReactDevTools() {
val currentActivity = Exponent.instance.currentActivity as? ReactNativeActivity ?: return run {
FLog.e(
ReactConstants.TAG,
"Unable to get the current activity."
)
}
// Emit the `RCTDevMenuShown` for the app to reconnect react-devtools
// https://github.com/facebook/react-native/blob/22ba1e45c52edcc345552339c238c1f5ef6dfc65/Libraries/Core/setUpReactDevTools.js#L80
currentActivity.emitRCTNativeAppEvent("RCTDevMenuShown", null)
}

private fun createPackagerCommandHelpers(): Map<String, RequestHandler> {
// Attach listeners to the bundler's dev server web socket connection.
// This enables tools to automatically reload the client remotely (i.e. in expo-cli).
Expand All @@ -163,6 +175,7 @@ object VersionedUtils {
}
"toggleElementInspector" -> toggleElementInspector()
"togglePerformanceMonitor" -> togglePerformanceMonitor()
"reconnectReactDevTools" -> reconnectReactDevTools()
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions ios/Exponent/Kernel/ReactAppManager/EXReactAppManager.mm
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,15 @@ - (void)toggleElementInspector
}
}

- (void)reconnectReactDevTools
{
if ([self enablesDeveloperTools]) {
// Emit the `RCTDevMenuShown` for the app to reconnect react-devtools
// https://github.com/facebook/react-native/blob/22ba1e45c52edcc345552339c238c1f5ef6dfc65/Libraries/Core/setUpReactDevTools.js#L80
[self.reactBridge enqueueJSCall:@"RCTNativeAppEventEmitter.emit" args:@[@"RCTDevMenuShown"]];
}
}

- (void)toggleDevMenu
{
if ([EXEnvironment sharedEnvironment].isDetached) {
Expand Down Expand Up @@ -543,6 +552,8 @@ - (void)setupWebSocketControls
[weakSelf toggleElementInspector];
} else if ([name isEqualToString:@"togglePerformanceMonitor"]) {
[weakSelf togglePerformanceMonitor];
} else if ([name isEqualToString:@"reconnectReactDevTools"]) {
[weakSelf reconnectReactDevTools];
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/@expo/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### 🎉 New features

- Added experimental react-devtools integration. ([#21462](https://github.com/expo/expo/pull/21462) by [@kudo](https://github.com/kudo))

### 🐛 Bug fixes

### 💡 Others
Expand Down
3 changes: 2 additions & 1 deletion packages/@expo/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@
"terminal-link": "^2.1.1",
"text-table": "^0.2.0",
"url-join": "4.0.0",
"wrap-ansi": "^7.0.0"
"wrap-ansi": "^7.0.0",
"ws": "^8.12.1"
},
"taskr": {
"requires": [
Expand Down
29 changes: 28 additions & 1 deletion packages/@expo/cli/src/start/interface/interactiveActions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { openJsInspector, queryAllInspectorAppsAsync } from '@expo/dev-server';
import assert from 'assert';
import openBrowserAsync from 'better-opn';
import chalk from 'chalk';

import * as Log from '../../log';
import { delayAsync } from '../../utils/delay';
import { learnMore } from '../../utils/link';
import { selectAsync } from '../../utils/prompts';
import { DevServerManager } from '../server/DevServerManager';
import {
addReactDevToolsReloadListener,
startReactDevToolsProxyAsync,
} from '../server/ReactDevToolsProxy';
import { BLT, printHelp, printItem, printQRCode, printUsage, StartOptions } from './commandsTable';

const debug = require('debug')('expo:start:interface:interactiveActions') as typeof console.log;
Expand Down Expand Up @@ -97,11 +103,16 @@ export class DevServerManagerActions {
{ title: 'Toggle performance monitor', value: 'togglePerformanceMonitor' },
{ title: 'Toggle developer menu', value: 'toggleDevMenu' },
{ title: 'Reload app', value: 'reload' },
{ title: 'Start React devtools', value: 'startReactDevTools' },
// TODO: Maybe a "View Source" option to open code.
// Toggling Remote JS Debugging is pretty rough, so leaving it disabled.
// { title: 'Toggle Remote Debugging', value: 'toggleRemoteDebugging' },
]);
this.devServerManager.broadcastMessage('sendDevCommand', { name: value });
if (value === 'startReactDevTools') {
this.startReactDevToolsAsync();
} else {
this.devServerManager.broadcastMessage('sendDevCommand', { name: value });
}
} catch (error: any) {
debug(error);
// do nothing
Expand All @@ -110,6 +121,22 @@ export class DevServerManagerActions {
}
}

async startReactDevToolsAsync() {
await startReactDevToolsProxyAsync();
const url = this.devServerManager.getDefaultDevServer().getReactDevToolsUrl();
await openBrowserAsync(url);
addReactDevToolsReloadListener(() => {
this.reconnectReactDevTools();
});
this.reconnectReactDevTools();
}

async reconnectReactDevTools() {
// Wait a little time for react-devtools to be initialized in browser
await delayAsync(3000);
this.devServerManager.broadcastMessage('sendDevCommand', { name: 'reconnectReactDevTools' });
}

toggleDevMenu() {
Log.log(`${BLT} Toggling dev menu`);
this.devServerManager.broadcastMessage('devMenu');
Expand Down
7 changes: 7 additions & 0 deletions packages/@expo/cli/src/start/server/BundlerDevServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,13 @@ export abstract class BundlerDevServer {
);
}

public getReactDevToolsUrl(): string {
return new URL(
'_expo/react-devtools',
this.getUrlCreator().constructUrl({ scheme: 'http' })
).toString();
}

protected async getPlatformManagerAsync(platform: keyof typeof PLATFORM_MANAGERS) {
if (!this.platformManagers[platform]) {
const Manager = PLATFORM_MANAGERS[platform]();
Expand Down
77 changes: 77 additions & 0 deletions packages/@expo/cli/src/start/server/ReactDevToolsProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'assert';
import { EventEmitter } from 'events';
import WebSocket from 'ws';

let serverInstance: WebSocket.WebSocketServer | null = null;

const eventEmitter = new EventEmitter();

/**
* Private command to support DevTools frontend reload.
*
* The react-devtools maintains state between frontend(webpage) and backend(app).
* If we reload the frontend without reloading the app, the react-devtools will stuck on incorrect state.
* We introduce this special reload command.
* As long as the frontend reload, we will close app's WebSocket connection and tell app to reconnect again.
*/
const RELOAD_COMMAND = 'Expo::RELOAD';

/**
* Start the react-devtools WebSocket proxy server
*/
export async function startReactDevToolsProxyAsync(options?: { port: number }) {
if (serverInstance != null) {
return;
}

serverInstance = new WebSocket.WebSocketServer({ port: options?.port ?? 8097 });

serverInstance.on('connection', function connection(ws) {
ws.on('message', function message(rawData, isBinary) {
assert(!isBinary);
const data = rawData.toString();

if (data === RELOAD_COMMAND) {
closeAllOtherClients(ws);
eventEmitter.emit(RELOAD_COMMAND);
return;
}

serverInstance?.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});

serverInstance.on('close', function () {
serverInstance = null;
});
}

/**
* Close the WebSocket server
*/
export function closeReactDevToolsProxy() {
serverInstance?.close();
serverInstance = null;
}

/**
* add event listener from react-devtools frontend reload
*/
export function addReactDevToolsReloadListener(listener: (...args: any[]) => void) {
eventEmitter.addListener(RELOAD_COMMAND, listener);
}

/**
* Close all other WebSocket clients other than the current `self` client
*/
function closeAllOtherClients(self: WebSocket.WebSocket) {
serverInstance?.clients.forEach(function each(client) {
if (client !== self && client.readyState === WebSocket.OPEN) {
client.close();
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../Bun
import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware';
import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware';
import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware';
import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware';
import {
DeepLinkHandler,
RuntimeRedirectMiddleware,
Expand Down Expand Up @@ -77,6 +78,7 @@ export class MetroBundlerDevServer extends BundlerDevServer {
scheme: options.location.scheme ?? null,
}).getHandler()
);
middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler());

const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, {
onDeepLink: getDeepLinkHandler(this.projectRoot),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readFile } from 'fs/promises';
import path from 'path';
import resolveFrom from 'resolve-from';

import { ExpoMiddleware } from './ExpoMiddleware';
import { ServerRequest, ServerResponse } from './server.types';

export const ReactDevToolsEndpoint = '/_expo/react-devtools';

export class ReactDevToolsPageMiddleware extends ExpoMiddleware {
constructor(projectRoot: string) {
super(projectRoot, [ReactDevToolsEndpoint]);
}

async handleRequestAsync(req: ServerRequest, res: ServerResponse): Promise<void> {
const templatePath =
// Production: This will resolve when installed in the project.
resolveFrom.silent(this.projectRoot, 'expo/static/react-devtools-page/index.html') ??
// Development: This will resolve when testing locally.
path.resolve(__dirname, '../../../../../static/react-devtools-page/index.html');
const content = (await readFile(templatePath)).toString('utf-8');

res.setHeader('Content-Type', 'text/html');
res.end(content);
}
}
Loading

0 comments on commit 68825a6

Please sign in to comment.