Files
flipper/desktop/plugins/public/reactdevtools/index.tsx
Andrey Goncharov 68aec1df60 Decapitate React DevTools
Summary:
Changelog: Migrate from react-devtools-core to -react-devtools-inline

Technical design doc: https://docs.google.com/document/d/1STUSUhXzrW_KkvqSu7Ge-rxjVFF7oU3_NbwzimkO_Z4

At this point, React DevTools doe snot support globally installed DevTools. Only the bundled version. The support for the globally installed DevTools comes in the subsequent diffs along with on-the-fly transpilation.

Reviewed By: mweststrate

Differential Revision: D34926472

fbshipit-source-id: fde1d4cf386adfbf8a8581ee5a54e950d2cb34ef
2022-03-31 04:01:33 -07:00

384 lines
11 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {createRoot} from 'react-dom/client';
import {
Layout,
usePlugin,
DevicePluginClient,
createState,
useValue,
Toolbar,
} from 'flipper-plugin';
import React from 'react';
import {Button, message, Switch, Typography} from 'antd';
// @ts-expect-error
import * as ReactDevToolsOSS from 'react-devtools-inline/frontend';
import {DevToolsEmbedder} from './DevToolsEmbedder';
import {Events, Methods} from './contract';
const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node';
const CONNECTED = 'DevTools connected';
enum ConnectionStatus {
None = 'None',
Initializing = 'Initializing...',
WaitingForReload = 'Waiting for connection from device...',
WaitingForMetroReload = 'Waiting for Metro to reload...',
Connected = 'Connected',
Error = 'Error',
}
type DevToolsInstanceType = 'global' | 'oss';
type DevToolsInstance = {
type: DevToolsInstanceType;
module: any;
};
export function devicePlugin(client: DevicePluginClient<Events, Methods>) {
const metroDevice = client.device;
const statusMessage = createState('Empty');
const connectionStatus = createState<ConnectionStatus>(ConnectionStatus.None);
const initialized = createState(false);
const globalDevToolsAvailable = createState(false);
let globalDevToolsInstance: DevToolsInstance | undefined;
const useGlobalDevTools = createState(false, {
persist: 'useGlobalDevTools',
persistToLocalStorage: true,
});
let devToolsInstance: DevToolsInstance | undefined;
const selectedDevToolsInstanceType = createState<DevToolsInstanceType>('oss');
let pollHandle: NodeJS.Timeout | undefined = undefined;
let metroReloadAttempts = 0;
async function maybeGetInitialGlobalDevTools(): Promise<DevToolsInstance> {
console.debug(
'flipper-plugin-react-devtools.maybeGetInitialGlobalDevTools',
);
try {
const newGlobalDevToolsSource = await client.sendToServerAddOn(
'globalDevTools',
);
if (newGlobalDevToolsSource) {
globalDevToolsInstance = {
type: 'global',
// eslint-disable-next-line no-eval
module: eval(newGlobalDevToolsSource),
};
globalDevToolsAvailable.set(true);
}
} catch (e) {
console.error(
'flipper-plugin-react-devtools.maybeGetInitialGlobalDevTools -> failed to load global devtools',
e,
);
}
if (useGlobalDevTools.get() && globalDevToolsInstance) {
console.debug(
'flipper-plugin-react-devtools.maybeGetInitialGlobalDevTools -> using global devtools',
);
return globalDevToolsInstance;
}
useGlobalDevTools.set(false); // disable in case it was enabled
console.debug(
'flipper-plugin-react-devtools.maybeGetInitialGlobalDevTools -> using OSS devtools',
);
return {type: 'oss', module: ReactDevToolsOSS};
}
function getDevToolsInstance(
instanceType: DevToolsInstanceType,
): DevToolsInstance {
let module;
switch (instanceType) {
case 'global':
module = globalDevToolsInstance!.module;
break;
case 'oss':
module = ReactDevToolsOSS;
break;
}
return {
type: instanceType,
module,
};
}
async function toggleUseGlobalDevTools() {
if (!globalDevToolsInstance) {
message.warn(
"No globally installed react-devtools package found. Run 'npm install -g react-devtools'.",
);
return;
}
selectedDevToolsInstanceType.update((prev: DevToolsInstanceType) => {
devToolsInstance = getDevToolsInstance(
prev === 'global' ? 'oss' : 'global',
);
return devToolsInstance.type;
});
useGlobalDevTools.update((v) => !v);
await rebootDevTools();
}
async function rebootDevTools() {
metroReloadAttempts = 0;
setStatus(ConnectionStatus.None, 'Loading DevTools...');
// clean old instance
if (pollHandle) {
clearTimeout(pollHandle);
}
await bootDevTools();
}
async function bootDevTools() {
if (connectionStatus.get() !== ConnectionStatus.None) {
return;
}
if (!initialized.get()) {
console.debug(
'flipper-plugin-react-devtools -> waiting for initialization',
);
await new Promise<void>((resolve) =>
initialized.subscribe((newInitialized) => {
if (newInitialized) {
resolve();
}
}),
);
}
const devToolsNode = document.getElementById(DEV_TOOLS_NODE_ID);
if (!devToolsNode) {
setStatus(ConnectionStatus.Error, 'Failed to find target DOM Node');
return;
}
if (devtoolsHaveStarted()) {
setStatus(ConnectionStatus.Connected, CONNECTED);
return;
}
// They're new!
try {
console.debug('flipper-plugin-react-devtools -> waiting for device');
setStatus(ConnectionStatus.Initializing, 'Waiting for device...');
client.onServerAddOnMessage('connected', () => {
if (pollHandle) {
clearTimeout(pollHandle);
}
console.debug('flipper-plugin-react-devtools -> device found');
setStatus(
ConnectionStatus.Initializing,
'Device found. Initializing frontend...',
);
const wall = {
listen(listener: any) {
client.onServerAddOnMessage('message', (data) => {
console.debug(
'flipper-plugin-react-devtools.onServerAddOnMessage',
data,
);
listener(data);
});
},
send(event: any, payload: any) {
const data = {event, payload};
client.sendToServerAddOn('message', data);
},
};
const bridge = devToolsInstance!.module.createBridge(window, wall);
const store = devToolsInstance!.module.createStore(bridge);
const DevTools = devToolsInstance!.module.initialize(window, {
bridge,
store,
});
const root = createRoot(devToolsNode);
root.render(React.createElement(DevTools));
console.debug('flipper-plugin-react-devtools -> connected');
setStatus(ConnectionStatus.Connected, 'Connected');
});
startPollForConnection();
} catch (e) {
console.error('Failed to initalize React DevTools' + e);
setStatus(ConnectionStatus.Error, 'Failed to initialize DevTools: ' + e);
}
}
function setStatus(cs: ConnectionStatus, status: string) {
connectionStatus.set(cs);
if (status.startsWith('The server is listening on')) {
statusMessage.set(status + ' Waiting for connection...');
} else {
statusMessage.set(status);
}
}
function startPollForConnection(delay = 3000) {
pollHandle = setTimeout(async () => {
switch (true) {
// Found DevTools!
case devtoolsHaveStarted():
setStatus(ConnectionStatus.Connected, CONNECTED);
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: {
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,
"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,
'DevTools is unable to connect yet. Check for other instances, trigger the DevMenu in the RN app, or reload it to connect.',
);
startPollForConnection();
return;
}
}, delay);
}
function devtoolsHaveStarted() {
return (
(document.getElementById(DEV_TOOLS_NODE_ID)?.childElementCount ?? 0) > 0
);
}
client.onReady(() => {
client.onServerAddOnStart(async () => {
devToolsInstance = await maybeGetInitialGlobalDevTools();
initialized.set(true);
});
});
client.onActivate(() => {
client.onServerAddOnStart(async () => {
bootDevTools();
});
});
client.onDeactivate(() => {
if (pollHandle) {
clearTimeout(pollHandle);
}
});
return {
devtoolsHaveStarted,
connectionStatus,
statusMessage,
bootDevTools,
rebootDevTools,
metroDevice,
globalDevToolsAvailable,
useGlobalDevTools,
selectedDevToolsInstanceType,
toggleUseGlobalDevTools,
initialized,
};
}
export function Component() {
const instance = usePlugin(devicePlugin);
const globalDevToolsAvailable = useValue(instance.globalDevToolsAvailable);
const connectionStatus = useValue(instance.connectionStatus);
const displayToolbar =
globalDevToolsAvailable || connectionStatus !== ConnectionStatus.Connected;
return (
<>
<DevToolsInstanceToolbar />
<DevToolsEmbedder
offset={displayToolbar ? 40 : 0}
nodeId={DEV_TOOLS_NODE_ID}
/>
</>
);
}
function DevToolsInstanceToolbar() {
const instance = usePlugin(devicePlugin);
const globalDevToolsAvailable = useValue(instance.globalDevToolsAvailable);
const connectionStatus = useValue(instance.connectionStatus);
const statusMessage = useValue(instance.statusMessage);
const useGlobalDevTools = useValue(instance.useGlobalDevTools);
const initialized = useValue(instance.initialized);
const selectionControl = globalDevToolsAvailable ? (
<>
<Switch
checked={useGlobalDevTools}
onChange={instance.toggleUseGlobalDevTools}
size="small"
disabled={!initialized}
/>
Use globally installed DevTools
</>
) : null;
return (
<Layout.Container grow>
<Toolbar right={selectionControl} wash>
<Typography.Text type="secondary">{statusMessage}</Typography.Text>
{connectionStatus === ConnectionStatus.WaitingForReload ||
connectionStatus === ConnectionStatus.WaitingForMetroReload ||
connectionStatus === ConnectionStatus.Error ? (
<Button
size="small"
onClick={() => {
instance.metroDevice?.sendMetroCommand('reload');
instance.rebootDevTools();
}}>
Retry
</Button>
) : null}
</Toolbar>
</Layout.Container>
);
}