desktop/plugins/public/reactdevtools/index.tsx (332 lines of code) (raw):
/**
* 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, Root} 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;
let devToolsInstance: DevToolsInstance | undefined;
const selectedDevToolsInstanceType = createState<DevToolsInstanceType>(
'oss',
{
persist: 'selectedDevToolsInstanceType',
persistToLocalStorage: true,
},
);
let root: Root | undefined;
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 (
selectedDevToolsInstanceType.get() === 'global' &&
globalDevToolsInstance
) {
console.debug(
'flipper-plugin-react-devtools.maybeGetInitialGlobalDevTools -> using global devtools',
);
return globalDevToolsInstance;
}
selectedDevToolsInstanceType.set('oss'); // 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;
});
await rebootDevTools();
}
async function rebootDevTools() {
metroReloadAttempts = 0;
setStatus(ConnectionStatus.None, 'Loading DevTools...');
// clean old instance
if (pollHandle) {
clearTimeout(pollHandle);
}
const devToolsNode = document.getElementById(DEV_TOOLS_NODE_ID);
if (!devToolsNode) {
setStatus(ConnectionStatus.Error, 'Failed to find target DOM Node');
return;
}
if (root) {
root.unmount();
}
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,
});
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,
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 selectedDevToolsInstanceType = useValue(
instance.selectedDevToolsInstanceType,
);
const initialized = useValue(instance.initialized);
const selectionControl = globalDevToolsAvailable ? (
<>
<Switch
checked={selectedDevToolsInstanceType === 'global'}
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>
);
}