Allow switching between internal and OSS DevTools (#3139)

Summary:
Pull Request resolved: https://github.com/facebook/flipper/pull/3139

This commit adds new UI in the top level toolbar to allow internal FB users to switch between the internal build of devtools and the OSS one.

## Scenarios

**Internal (when `client.isFB`)**

- DevTools version will default to the internal version, and will render a `Select` component with option to switch to the OSS version.
- If a global install of DevTools is present, the selection menu will also offer the option to switch to the global DevTools version.

**External (when `!client.isFB`)**
Will preserve previous behavior:

- Uses the OSS version by default, and doesn't provide option to switch to internal version.
- If a global installation is present, will render a `Switch` component that allows switching between OSS and global installation.

### Implementation

This commit refactors some parts of the DevTools plugin to provide a bit more clarity in the loading sequence by renaming and modifying some of the messaging, and fixing lint warnings.

A change introduced here is that when switching or loading devtools, when we attempt to reload the device via Metro, don't immediately show a "Retry" button, since at that point nothing has gone wrong, and the Retry button will only occur if the Metro reload doesn't occur after a few seconds.

In a future commit, this [PR in Devtools](https://github.com/facebook/react/pull/22848) will allow us to clear any loading messages once DevTools has successfully connected.

Reviewed By: lunaruan, mweststrate

Differential Revision: D32773200

fbshipit-source-id: aa15ffecba7b2b2ea74e109e9f16334d47bf5868
This commit is contained in:
Juan Tejada
2021-12-06 12:40:16 -08:00
committed by Facebook GitHub Bot
parent 618670d00a
commit f9547e024e
2 changed files with 207 additions and 70 deletions

View File

@@ -0,0 +1,14 @@
/**
* 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
*/
export function getInternalDevToolsModule<TModule>(): TModule {
throw new Error(
"Can't require internal version of React DevTools from public version of Flipper.",
);
}

View File

@@ -20,10 +20,11 @@ import {
} from 'flipper-plugin'; } from 'flipper-plugin';
import React from 'react'; import React from 'react';
import getPort from 'get-port'; import getPort from 'get-port';
import {Button, message, Switch, Typography} from 'antd'; import {Button, Select, message, Switch, Typography} from 'antd';
import child_process from 'child_process'; import child_process from 'child_process';
import fs from 'fs'; import fs from 'fs';
import {DevToolsEmbedder} from './DevToolsEmbedder'; import {DevToolsEmbedder} from './DevToolsEmbedder';
import {getInternalDevToolsModule} from './fb-stubs/getInternalDevToolsModule';
const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node'; const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node';
const CONNECTED = 'DevTools connected'; const CONNECTED = 'DevTools connected';
@@ -55,10 +56,17 @@ function findGlobalDevTools(): Promise<string | undefined> {
enum ConnectionStatus { enum ConnectionStatus {
Initializing = 'Initializing...', Initializing = 'Initializing...',
WaitingForReload = 'Waiting for connection from device...', WaitingForReload = 'Waiting for connection from device...',
WaitingForMetroReload = 'Waiting for Metro to reload...',
Connected = 'Connected', Connected = 'Connected',
Error = 'Error', Error = 'Error',
} }
type DevToolsInstanceType = 'global' | 'internal' | 'oss';
type DevToolsInstance = {
type: DevToolsInstanceType;
module: ReactDevToolsStandaloneType;
};
export function devicePlugin(client: DevicePluginClient) { export function devicePlugin(client: DevicePluginClient) {
const metroDevice = client.device; const metroDevice = client.device;
@@ -72,28 +80,86 @@ export function devicePlugin(client: DevicePluginClient) {
persistToLocalStorage: true, persistToLocalStorage: true,
}); });
let devToolsInstance = getDefaultDevToolsModule(); let devToolsInstance = getDefaultDevToolsInstance();
const selectedDevToolsInstanceType = createState<DevToolsInstanceType>(
devToolsInstance.type,
);
let startResult: {close(): void} | undefined = undefined; let startResult: {close(): void} | undefined = undefined;
let pollHandle: NodeJS.Timeout | undefined = undefined; let pollHandle: NodeJS.Timeout | undefined = undefined;
function getDevToolsModule() { let metroReloadAttempts = 0;
function getGlobalDevToolsModule(): ReactDevToolsStandaloneType {
const required = global.electronRequire(globalDevToolsPath.get()!).default;
return required.default ?? required;
}
function getOSSDevToolsModule(): ReactDevToolsStandaloneType {
const required = require('react-devtools-core/standalone').default;
return required.default ?? required;
}
function getInitialDevToolsInstance(): DevToolsInstance {
// Load right library // Load right library
if (useGlobalDevTools.get()) { if (useGlobalDevTools.get()) {
const module = global.electronRequire(globalDevToolsPath.get()!); return {
return module.default ?? module; type: 'global',
module: getGlobalDevToolsModule(),
};
} else { } else {
return getDefaultDevToolsModule(); return getDefaultDevToolsInstance();
} }
} }
function getDefaultDevToolsModule(): ReactDevToolsStandaloneType { function getDefaultDevToolsInstance(): DevToolsInstance {
return client.isFB const type = client.isFB ? 'internal' : 'oss';
? require('./fb/react-devtools-core/standalone').default ?? const module = client.isFB
require('./fb/react-devtools-core/standalone') ? getInternalDevToolsModule<ReactDevToolsStandaloneType>()
: require('react-devtools-core/standalone').default ?? : getOSSDevToolsModule();
require('react-devtools-core/standalone'); return {type, module};
}
function getDevToolsInstance(
instanceType: DevToolsInstanceType,
): DevToolsInstance {
let module;
switch (instanceType) {
case 'global':
module = getGlobalDevToolsModule();
break;
case 'internal':
module = getInternalDevToolsModule<ReactDevToolsStandaloneType>();
break;
case 'oss':
module = getOSSDevToolsModule();
break;
}
return {
type: instanceType,
module,
};
}
async function setDevToolsInstance(instanceType: DevToolsInstanceType) {
selectedDevToolsInstanceType.set(instanceType);
if (instanceType === 'global') {
if (!globalDevToolsPath.get()) {
message.warn(
"No globally installed react-devtools package found. Run 'npm install -g react-devtools'.",
);
return;
}
useGlobalDevTools.set(true);
} else {
useGlobalDevTools.set(false);
}
devToolsInstance = getDevToolsInstance(instanceType);
await rebootDevTools();
} }
async function toggleUseGlobalDevTools() { async function toggleUseGlobalDevTools() {
@@ -103,18 +169,29 @@ export function devicePlugin(client: DevicePluginClient) {
); );
return; return;
} }
selectedDevToolsInstanceType.update((prev: DevToolsInstanceType) => {
if (prev === 'global') {
devToolsInstance = getDefaultDevToolsInstance();
return devToolsInstance.type;
} else {
devToolsInstance = getDevToolsInstance('global');
return devToolsInstance.type;
}
});
useGlobalDevTools.update((v) => !v); useGlobalDevTools.update((v) => !v);
devToolsInstance = getDevToolsModule(); await rebootDevTools();
}
statusMessage.set('Switching devTools'); async function rebootDevTools() {
connectionStatus.set(ConnectionStatus.Initializing); metroReloadAttempts = 0;
setStatus(ConnectionStatus.Initializing, 'Loading DevTools...');
// clean old instance // clean old instance
if (pollHandle) { if (pollHandle) {
clearTimeout(pollHandle); clearTimeout(pollHandle);
} }
startResult?.close(); startResult?.close();
await sleep(1000); // wait for port to close await sleep(5000); // wait for port to close
startResult = undefined; startResult = undefined;
await bootDevTools(); await bootDevTools();
} }
@@ -152,16 +229,16 @@ export function devicePlugin(client: DevicePluginClient) {
} }
setStatus( setStatus(
ConnectionStatus.Initializing, ConnectionStatus.Initializing,
'Starting DevTools server on ' + port, 'Starting DevTools server on ' + DEV_TOOLS_PORT,
); );
startResult = devToolsInstance startResult = devToolsInstance.module
.setContentDOMNode(devToolsNode) .setContentDOMNode(devToolsNode)
.setStatusListener((status: string) => { .setStatusListener((status: string) => {
// TODO: since devToolsInstance is an instance, we are probably leaking memory here // TODO: since devToolsInstance is an instance, we are probably leaking memory here
setStatus(ConnectionStatus.Initializing, status); setStatus(ConnectionStatus.Initializing, status);
}) })
.startServer(port) as any; .startServer(DEV_TOOLS_PORT) as any;
setStatus(ConnectionStatus.Initializing, 'Waiting for device'); setStatus(ConnectionStatus.Initializing, 'Waiting for device...');
} catch (e) { } catch (e) {
console.error('Failed to initalize React DevTools' + e); console.error('Failed to initalize React DevTools' + e);
setStatus(ConnectionStatus.Error, 'Failed to initialize DevTools: ' + e); setStatus(ConnectionStatus.Error, 'Failed to initialize DevTools: ' + e);
@@ -169,7 +246,7 @@ export function devicePlugin(client: DevicePluginClient) {
setStatus( setStatus(
ConnectionStatus.Initializing, ConnectionStatus.Initializing,
'DevTools have been initialized, waiting for connection...', 'DevTools initialized, waiting for connection...',
); );
if (devtoolsHaveStarted()) { if (devtoolsHaveStarted()) {
setStatus(ConnectionStatus.Connected, CONNECTED); setStatus(ConnectionStatus.Connected, CONNECTED);
@@ -196,27 +273,33 @@ export function devicePlugin(client: DevicePluginClient) {
return; return;
// Waiting for connection, but we do have an active Metro connection, lets force a reload to enter Dev Mode on app // Waiting for connection, but we do have an active Metro connection, lets force a reload to enter Dev Mode on app
// prettier-ignore // prettier-ignore
case connectionStatus.get() === ConnectionStatus.Initializing: case connectionStatus.get() === ConnectionStatus.Initializing: {
if (metroDevice) {
const nextConnectionStatus = metroReloadAttempts === 0 ? ConnectionStatus.Initializing : ConnectionStatus.WaitingForMetroReload;
metroReloadAttempts++;
setStatus(
nextConnectionStatus,
"Sending 'reload' to Metro to force DevTools to connect...",
);
metroDevice.sendMetroCommand('reload');
startPollForConnection(3000);
return;
}
// Waiting for initial connection, but no WS bridge available
setStatus( setStatus(
ConnectionStatus.WaitingForReload, ConnectionStatus.WaitingForReload,
"Sending 'reload' to Metro to force the DevTools to connect...", "DevTools is unable to connect yet. Please trigger the DevMenu in the RN app, or reload it to connect.",
);
metroDevice!.sendMetroCommand('reload');
startPollForConnection(2000);
return;
// Waiting for initial connection, but no WS bridge available
case connectionStatus.get() === ConnectionStatus.Initializing:
setStatus(
ConnectionStatus.WaitingForReload,
"The DevTools didn't connect yet. Please trigger the DevMenu in the React Native app, or Reload it to connect.",
); );
startPollForConnection(10000); startPollForConnection(10000);
return; return;
}
// Still nothing? Users might not have done manual action, or some other tools have picked it up? // Still nothing? Users might not have done manual action, or some other tools have picked it up?
case connectionStatus.get() === ConnectionStatus.WaitingForReload: case connectionStatus.get() === ConnectionStatus.WaitingForReload:
case connectionStatus.get() === ConnectionStatus.WaitingForMetroReload:
setStatus( setStatus(
ConnectionStatus.WaitingForReload, ConnectionStatus.WaitingForReload,
"The DevTools didn't connect yet. Check if no other instances are running.", 'DevTools is unable to connect yet. Check for other instances, trigger the DevMenu in the RN app, or reload it to connect.',
); );
startPollForConnection(); startPollForConnection();
return; return;
@@ -234,9 +317,10 @@ export function devicePlugin(client: DevicePluginClient) {
const path = await findGlobalDevTools(); const path = await findGlobalDevTools();
if (path) { if (path) {
globalDevToolsPath.set(path + '/standalone'); globalDevToolsPath.set(path + '/standalone');
selectedDevToolsInstanceType.set('global');
console.log('Found global React DevTools: ', path); console.log('Found global React DevTools: ', path);
// load it, if the flag is set // load it, if the flag is set
devToolsInstance = getDevToolsModule(); devToolsInstance = getInitialDevToolsInstance();
} else { } else {
useGlobalDevTools.set(false); // disable in case it was enabled useGlobalDevTools.set(false); // disable in case it was enabled
} }
@@ -257,57 +341,96 @@ export function devicePlugin(client: DevicePluginClient) {
}); });
return { return {
isFB: client.isFB,
devtoolsHaveStarted, devtoolsHaveStarted,
connectionStatus, connectionStatus,
statusMessage, statusMessage,
bootDevTools, bootDevTools,
rebootDevTools,
metroDevice, metroDevice,
globalDevToolsPath, globalDevToolsPath,
useGlobalDevTools, useGlobalDevTools,
selectedDevToolsInstanceType,
setDevToolsInstance,
toggleUseGlobalDevTools, toggleUseGlobalDevTools,
}; };
} }
export function Component() { export function Component() {
const instance = usePlugin(devicePlugin);
const connectionStatus = useValue(instance.connectionStatus);
const statusMessage = useValue(instance.statusMessage);
const globalDevToolsPath = useValue(instance.globalDevToolsPath);
const useGlobalDevTools = useValue(instance.useGlobalDevTools);
return ( return (
<Layout.Container grow> <Layout.Container grow>
{globalDevToolsPath ? ( <DevToolsInstanceToolbar />
<Toolbar
right={
<>
<Switch
checked={useGlobalDevTools}
onChange={instance.toggleUseGlobalDevTools}
size="small"
/>
Use globally installed DevTools
</>
}
wash>
{connectionStatus !== ConnectionStatus.Connected ? (
<Typography.Text type="secondary">{statusMessage}</Typography.Text>
) : null}
{(connectionStatus === ConnectionStatus.WaitingForReload &&
instance.metroDevice) ||
connectionStatus === ConnectionStatus.Error ? (
<Button
size="small"
onClick={() => {
instance.metroDevice?.sendMetroCommand('reload');
instance.bootDevTools();
}}>
Retry
</Button>
) : null}
</Toolbar>
) : null}
<DevToolsEmbedder offset={40} nodeId={DEV_TOOLS_NODE_ID} /> <DevToolsEmbedder offset={40} nodeId={DEV_TOOLS_NODE_ID} />
</Layout.Container> </Layout.Container>
); );
} }
function DevToolsInstanceToolbar() {
const instance = usePlugin(devicePlugin);
const globalDevToolsPath = useValue(instance.globalDevToolsPath);
const connectionStatus = useValue(instance.connectionStatus);
const statusMessage = useValue(instance.statusMessage);
const useGlobalDevTools = useValue(instance.useGlobalDevTools);
const selectedDevToolsInstanceType = useValue(
instance.selectedDevToolsInstanceType,
);
if (!globalDevToolsPath && !instance.isFB) {
return null;
}
let selectionControl;
if (instance.isFB) {
const devToolsInstanceOptions = [{value: 'internal'}, {value: 'oss'}];
if (globalDevToolsPath) {
devToolsInstanceOptions.push({value: 'global'});
}
selectionControl = (
<>
Select preferred DevTools version:
<Select
options={devToolsInstanceOptions}
value={selectedDevToolsInstanceType}
onSelect={instance.setDevToolsInstance}
style={{width: 90}}
size="small"
/>
</>
);
} else if (globalDevToolsPath) {
selectionControl = (
<>
<Switch
checked={useGlobalDevTools}
onChange={instance.toggleUseGlobalDevTools}
size="small"
/>
Use globally installed DevTools
</>
);
} else {
throw new Error(
'Should not render Toolbar if not FB build or a global DevTools install not available.',
);
}
return (
<Toolbar right={selectionControl} wash>
{connectionStatus !== ConnectionStatus.Connected ? (
<Typography.Text type="secondary">{statusMessage}</Typography.Text>
) : null}
{connectionStatus === ConnectionStatus.WaitingForReload ||
connectionStatus === ConnectionStatus.WaitingForMetroReload ||
connectionStatus === ConnectionStatus.Error ? (
<Button
size="small"
onClick={() => {
instance.metroDevice?.sendMetroCommand('reload');
instance.rebootDevTools();
}}>
Retry
</Button>
) : null}
</Toolbar>
);
}