Skip to content

Commit

Permalink
Allow switching between internal and OSS DevTools (#3139)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #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](facebook/react#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
  • Loading branch information
Juan Tejada authored and facebook-github-bot committed Dec 6, 2021
1 parent 618670d commit f9547e0
Show file tree
Hide file tree
Showing 2 changed files with 204 additions and 67 deletions.
Original file line number Diff line number Diff line change
@@ -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.",
);
}
257 changes: 190 additions & 67 deletions desktop/plugins/public/reactdevtools/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import {
} from 'flipper-plugin';
import React from 'react';
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 fs from 'fs';
import {DevToolsEmbedder} from './DevToolsEmbedder';
import {getInternalDevToolsModule} from './fb-stubs/getInternalDevToolsModule';

const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node';
const CONNECTED = 'DevTools connected';
Expand Down Expand Up @@ -55,10 +56,17 @@ function findGlobalDevTools(): Promise<string | undefined> {
enum ConnectionStatus {
Initializing = 'Initializing...',
WaitingForReload = 'Waiting for connection from device...',
WaitingForMetroReload = 'Waiting for Metro to reload...',
Connected = 'Connected',
Error = 'Error',
}

type DevToolsInstanceType = 'global' | 'internal' | 'oss';
type DevToolsInstance = {
type: DevToolsInstanceType;
module: ReactDevToolsStandaloneType;
};

export function devicePlugin(client: DevicePluginClient) {
const metroDevice = client.device;

Expand All @@ -72,28 +80,86 @@ export function devicePlugin(client: DevicePluginClient) {
persistToLocalStorage: true,
});

let devToolsInstance = getDefaultDevToolsModule();
let devToolsInstance = getDefaultDevToolsInstance();
const selectedDevToolsInstanceType = createState<DevToolsInstanceType>(
devToolsInstance.type,
);

let startResult: {close(): void} | 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
if (useGlobalDevTools.get()) {
const module = global.electronRequire(globalDevToolsPath.get()!);
return module.default ?? module;
return {
type: 'global',
module: getGlobalDevToolsModule(),
};
} else {
return getDefaultDevToolsModule();
return getDefaultDevToolsInstance();
}
}

function getDefaultDevToolsModule(): ReactDevToolsStandaloneType {
return client.isFB
? require('./fb/react-devtools-core/standalone').default ??
require('./fb/react-devtools-core/standalone')
: require('react-devtools-core/standalone').default ??
require('react-devtools-core/standalone');
function getDefaultDevToolsInstance(): DevToolsInstance {
const type = client.isFB ? 'internal' : 'oss';
const module = client.isFB
? getInternalDevToolsModule<ReactDevToolsStandaloneType>()
: getOSSDevToolsModule();
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() {
Expand All @@ -103,18 +169,29 @@ export function devicePlugin(client: DevicePluginClient) {
);
return;
}
selectedDevToolsInstanceType.update((prev: DevToolsInstanceType) => {
if (prev === 'global') {
devToolsInstance = getDefaultDevToolsInstance();
return devToolsInstance.type;
} else {
devToolsInstance = getDevToolsInstance('global');
return devToolsInstance.type;
}
});
useGlobalDevTools.update((v) => !v);

devToolsInstance = getDevToolsModule();
await rebootDevTools();
}

statusMessage.set('Switching devTools');
connectionStatus.set(ConnectionStatus.Initializing);
async function rebootDevTools() {
metroReloadAttempts = 0;
setStatus(ConnectionStatus.Initializing, 'Loading DevTools...');
// clean old instance
if (pollHandle) {
clearTimeout(pollHandle);
}
startResult?.close();
await sleep(1000); // wait for port to close
await sleep(5000); // wait for port to close
startResult = undefined;
await bootDevTools();
}
Expand Down Expand Up @@ -152,24 +229,24 @@ export function devicePlugin(client: DevicePluginClient) {
}
setStatus(
ConnectionStatus.Initializing,
'Starting DevTools server on ' + port,
'Starting DevTools server on ' + DEV_TOOLS_PORT,
);
startResult = devToolsInstance
startResult = devToolsInstance.module
.setContentDOMNode(devToolsNode)
.setStatusListener((status: string) => {
// TODO: since devToolsInstance is an instance, we are probably leaking memory here
setStatus(ConnectionStatus.Initializing, status);
})
.startServer(port) as any;
setStatus(ConnectionStatus.Initializing, 'Waiting for device');
.startServer(DEV_TOOLS_PORT) as any;
setStatus(ConnectionStatus.Initializing, 'Waiting for device...');
} catch (e) {
console.error('Failed to initalize React DevTools' + e);
setStatus(ConnectionStatus.Error, 'Failed to initialize DevTools: ' + e);
}

setStatus(
ConnectionStatus.Initializing,
'DevTools have been initialized, waiting for connection...',
'DevTools initialized, waiting for connection...',
);
if (devtoolsHaveStarted()) {
setStatus(ConnectionStatus.Connected, CONNECTED);
Expand All @@ -196,27 +273,33 @@ export function devicePlugin(client: DevicePluginClient) {
return;
// Waiting for connection, but we do have an active Metro connection, lets force a reload to enter Dev Mode on app
// prettier-ignore
case connectionStatus.get() === ConnectionStatus.Initializing:
setStatus(
ConnectionStatus.WaitingForReload,
"Sending 'reload' to Metro to force the DevTools to connect...",
);
metroDevice!.sendMetroCommand('reload');
startPollForConnection(2000);
return;
// Waiting for initial connection, but no WS bridge available
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(
ConnectionStatus.WaitingForReload,
"The DevTools didn't connect yet. Please trigger the DevMenu in the React Native app, or Reload it to connect.",
"DevTools is unable to connect yet. Please trigger the DevMenu in the RN app, or reload it to connect.",
);
startPollForConnection(10000);
return;
}
// 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.WaitingForMetroReload:
setStatus(
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();
return;
Expand All @@ -234,9 +317,10 @@ export function devicePlugin(client: DevicePluginClient) {
const path = await findGlobalDevTools();
if (path) {
globalDevToolsPath.set(path + '/standalone');
selectedDevToolsInstanceType.set('global');
console.log('Found global React DevTools: ', path);
// load it, if the flag is set
devToolsInstance = getDevToolsModule();
devToolsInstance = getInitialDevToolsInstance();
} else {
useGlobalDevTools.set(false); // disable in case it was enabled
}
Expand All @@ -257,57 +341,96 @@ export function devicePlugin(client: DevicePluginClient) {
});

return {
isFB: client.isFB,
devtoolsHaveStarted,
connectionStatus,
statusMessage,
bootDevTools,
rebootDevTools,
metroDevice,
globalDevToolsPath,
useGlobalDevTools,
selectedDevToolsInstanceType,
setDevToolsInstance,
toggleUseGlobalDevTools,
};
}

export function Component() {
return (
<Layout.Container grow>
<DevToolsInstanceToolbar />
<DevToolsEmbedder offset={40} nodeId={DEV_TOOLS_NODE_ID} />
</Layout.Container>
);
}

function DevToolsInstanceToolbar() {
const instance = usePlugin(devicePlugin);
const globalDevToolsPath = useValue(instance.globalDevToolsPath);
const connectionStatus = useValue(instance.connectionStatus);
const statusMessage = useValue(instance.statusMessage);
const globalDevToolsPath = useValue(instance.globalDevToolsPath);
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 (
<Layout.Container grow>
{globalDevToolsPath ? (
<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>
<Toolbar right={selectionControl} wash>
{connectionStatus !== ConnectionStatus.Connected ? (
<Typography.Text type="secondary">{statusMessage}</Typography.Text>
) : null}
<DevToolsEmbedder offset={40} nodeId={DEV_TOOLS_NODE_ID} />
</Layout.Container>
{connectionStatus === ConnectionStatus.WaitingForReload ||
connectionStatus === ConnectionStatus.WaitingForMetroReload ||
connectionStatus === ConnectionStatus.Error ? (
<Button
size="small"
onClick={() => {
instance.metroDevice?.sendMetroCommand('reload');
instance.rebootDevTools();
}}>
Retry
</Button>
) : null}
</Toolbar>
);
}

0 comments on commit f9547e0

Please sign in to comment.