diff --git a/desktop/app/src/store.tsx b/desktop/app/src/store.tsx index 7c8ca8cf8..24be600ac 100644 --- a/desktop/app/src/store.tsx +++ b/desktop/app/src/store.tsx @@ -13,6 +13,7 @@ import reducers, {Actions, State as StoreState, Store} from './reducers/index'; import {stateSanitizer} from './utils/reduxDevToolsConfig'; import isProduction from './utils/isProduction'; import {_SandyPluginDefinition} from 'flipper-plugin'; + export const store: Store = createStore( rootReducer, // @ts-ignore Type definition mismatch @@ -36,3 +37,7 @@ if (!isProduction()) { // @ts-ignore window.flipperStore = store; } +// Escape hatch during Sandy conversion; +// Some plugins directly interact with the Store and need further abstractions +// @ts-ignore +window.__SECRET_FLIPPER_STORE_DONT_USE_OR_YOU_WILL_BE_FIRED__ = store; diff --git a/desktop/plugins/public/reactdevtools/index.tsx b/desktop/plugins/public/reactdevtools/index.tsx index 15ec1fbbb..a86690a16 100644 --- a/desktop/plugins/public/reactdevtools/index.tsx +++ b/desktop/plugins/public/reactdevtools/index.tsx @@ -9,32 +9,25 @@ import ReactDevToolsStandalone from 'react-devtools-core/standalone'; import { - FlipperDevicePlugin, - AndroidDevice, - styled, - View, - MetroDevice, - ReduxState, - connect, - Device, - CenteredView, - RoundedSection, - Text, - Button, -} from 'flipper'; -import React, {useEffect} from 'react'; + Layout, + usePlugin, + DevicePluginClient, + createState, + useValue, + theme, + sleep, +} from 'flipper-plugin'; +import React, {createRef, useEffect} from 'react'; import getPort from 'get-port'; - -const Container = styled.div({ - display: 'flex', - flex: '1 1 0%', - justifyContent: 'center', - alignItems: 'stretch', - height: '100%', -}); +import {Alert, Button} from 'antd'; const DEV_TOOLS_NODE_ID = 'reactdevtools-out-of-react-node'; +interface MetroDevice { + ws?: WebSocket; + sendCommand(command: string, params?: any): void; +} + function createDevToolsNode(): HTMLElement { const div = document.createElement('div'); div.id = DEV_TOOLS_NODE_ID; @@ -65,30 +58,6 @@ function detachDevTools(devToolsNode: HTMLElement) { } const CONNECTED = 'DevTools connected'; - -type GrabMetroDeviceStoreProps = {metroDevice: MetroDevice}; -type GrabMetroDeviceOwnProps = {onHasDevice(device: MetroDevice): void}; - -// Utility component to grab the metroDevice from the store if there is one -const GrabMetroDevice = connect< - GrabMetroDeviceStoreProps, - {}, - GrabMetroDeviceOwnProps, - ReduxState ->(({connections: {devices}}) => ({ - metroDevice: devices.find( - (device: Device) => device.os === 'Metro' && !device.isArchived, - ) as MetroDevice, -}))(function ({ - metroDevice, - onHasDevice, -}: GrabMetroDeviceStoreProps & GrabMetroDeviceOwnProps) { - useEffect(() => { - onHasDevice(metroDevice); - }, [metroDevice, onHasDevice]); - return null; -}); - const SUPPORTED_OCULUS_DEVICE_TYPES = ['quest', 'go', 'pacific']; enum ConnectionStatus { @@ -98,184 +67,193 @@ enum ConnectionStatus { Error = 'Error', } -export default class ReactDevTools extends FlipperDevicePlugin< - {status: string}, - any, - {} -> { - static supportsDevice(device: Device) { - return !device.isArchived && device.os === 'Metro'; +export function devicePlugin(client: DevicePluginClient) { + const metroDevice: MetroDevice = client.device.realDevice; + if (!metroDevice.sendCommand || !('ws' in metroDevice)) { + throw new Error('Invalid metroDevice'); } - pollHandle?: NodeJS.Timeout; - containerRef: React.RefObject = React.createRef(); - connectionStatus: ConnectionStatus = ConnectionStatus.Initializing; - metroDevice?: MetroDevice; - isMounted = true; + const statusMessage = createState('initializing'); + const connectionStatus = createState( + ConnectionStatus.Initializing, + ); - state = { - status: 'initializing', - }; + const containerRef = createRef(); + let pollHandle: NodeJS.Timeout | undefined = undefined; + let isMounted = false; - componentDidMount() { - this.bootDevTools(); - } - - componentWillUnmount() { - this.isMounted = false; - if (this.pollHandle) { - clearTimeout(this.pollHandle); - } - const devToolsNode = findDevToolsNode(); - devToolsNode && detachDevTools(devToolsNode); - } - - setStatus(connectionStatus: ConnectionStatus, status: string) { - this.connectionStatus = connectionStatus; - if (!this.isMounted) { - return; - } - if (status.startsWith('The server is listening on')) { - this.setState({status: status + ' Waiting for connection...'}); - } else { - this.setState({status}); - } - } - - devtoolsHaveStarted() { - return !!findDevToolsNode()?.innerHTML; - } - - bootDevTools() { + async function bootDevTools() { + isMounted = true; let devToolsNode = findDevToolsNode(); if (!devToolsNode) { devToolsNode = createDevToolsNode(); - this.initializeDevTools(devToolsNode); } - this.setStatus( + attachDevTools(containerRef.current!, devToolsNode); + initializeDevTools(devToolsNode); + setStatus( ConnectionStatus.Initializing, 'DevTools have been initialized, waiting for connection...', ); - if (this.devtoolsHaveStarted()) { - this.setStatus(ConnectionStatus.Connected, CONNECTED); - } else { - this.startPollForConnection(); - } - attachDevTools(this.containerRef?.current!, devToolsNode); - this.startPollForConnection(); + await sleep(5); // give node time to move + if (devtoolsHaveStarted()) { + setStatus(ConnectionStatus.Connected, CONNECTED); + } else { + startPollForConnection(); + } } - startPollForConnection(delay = 3000) { - this.pollHandle = setTimeout(() => { + function setStatus(cs: ConnectionStatus, status: string) { + connectionStatus.set(cs); + if (!isMounted) { + return; + } + 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) { // Closed already, ignore - case !this.isMounted: + case !isMounted: return; // Found DevTools! - case this.devtoolsHaveStarted(): - this.setStatus(ConnectionStatus.Connected, CONNECTED); + 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 this.connectionStatus === ConnectionStatus.Initializing && !!this.metroDevice?.ws: - this.setStatus( + case connectionStatus.get() === ConnectionStatus.Initializing && !!metroDevice?.ws: + setStatus( ConnectionStatus.WaitingForReload, "Sending 'reload' to Metro to force the DevTools to connect...", ); - this.metroDevice!.sendCommand('reload'); - this.startPollForConnection(10000); + metroDevice!.sendCommand('reload'); + startPollForConnection(2000); return; // Waiting for initial connection, but no WS bridge available - case this.connectionStatus === ConnectionStatus.Initializing: - this.setStatus( + 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", ); - this.startPollForConnection(10000); + startPollForConnection(10000); return; // Still nothing? Users might not have done manual action, or some other tools have picked it up? - case this.connectionStatus === ConnectionStatus.WaitingForReload: - this.setStatus( + case connectionStatus.get() === ConnectionStatus.WaitingForReload: + setStatus( ConnectionStatus.WaitingForReload, "The DevTools didn't connect yet. Please verify your React Native app is in development mode, and that no other instance of the React DevTools are attached to the app already.", ); - this.startPollForConnection(); + startPollForConnection(); return; } }, delay); } - async initializeDevTools(devToolsNode: HTMLElement) { + function devtoolsHaveStarted() { + return (findDevToolsNode()?.childElementCount ?? 0) > 0; + } + + async function initializeDevTools(devToolsNode: HTMLElement) { try { - this.setStatus(ConnectionStatus.Initializing, 'Waiting for port 8097'); + setStatus(ConnectionStatus.Initializing, 'Waiting for port 8097'); const port = await getPort({port: 8097}); // default port for dev tools - this.setStatus( + setStatus( ConnectionStatus.Initializing, 'Starting DevTools server on ' + port, ); + // Currently a new port is negotatiated every time the plugin is opened. + // This can be potentially optimized by keeping the devTools instance around ReactDevToolsStandalone.setContentDOMNode(devToolsNode) .setStatusListener((status) => { - this.setStatus(ConnectionStatus.Initializing, status); + setStatus(ConnectionStatus.Initializing, status); }) .startServer(port); - this.setStatus(ConnectionStatus.Initializing, 'Waiting for device'); - const device = this.device; + setStatus(ConnectionStatus.Initializing, 'Waiting for device'); - if (device) { - if ( - device.deviceType === 'physical' || - SUPPORTED_OCULUS_DEVICE_TYPES.includes(device.title.toLowerCase()) - ) { - this.setStatus( - ConnectionStatus.Initializing, - `Setting up reverse port mapping: ${port}:${port}`, - ); - (device as AndroidDevice).reverse([port, port]); - } - } + // This is a hack that should be cleaned up. Instead of setting up port forwarding + // for any physical android device, we should introduce a mechanism to detect all connected + // metro apps, and connect to one of them. + // Since this is not how we want (or can) reliably detect the device we intend to interact with, + // leaving this here until we can get a list of connected applications & ports from Metro or Flipper + (window as any).__SECRET_FLIPPER_STORE_DONT_USE_OR_YOU_WILL_BE_FIRED__ + .getState() + .connections.devices.forEach((d: any) => { + if ( + (d.deviceType === 'physical' && d.os === 'Android') || + SUPPORTED_OCULUS_DEVICE_TYPES.includes(d.title.toLowerCase()) + ) { + console.log( + `[React DevTools] Forwarding port ${port} for device ${d.title}`, + ); + d.reverse([port]); + } + }); } catch (e) { - console.error(e); - this.setStatus( - ConnectionStatus.Error, - 'Failed to initialize DevTools: ' + e, - ); + console.error('Failed to initalize React DevTools' + e); + setStatus(ConnectionStatus.Error, 'Failed to initialize DevTools: ' + e); } } - render() { - return ( - - {!this.devtoolsHaveStarted() ? this.renderStatus() : null} - - { - this.metroDevice = device; - }} - /> - - ); + function stopDevtools() { + isMounted = false; + if (pollHandle) { + clearTimeout(pollHandle); + } + const devToolsNode = findDevToolsNode(); + if (devToolsNode) { + detachDevTools(devToolsNode); + } } - renderStatus() { - return ( - - - {this.state.status} - {(this.connectionStatus === ConnectionStatus.WaitingForReload && - this.metroDevice?.ws) || - this.connectionStatus === ConnectionStatus.Error ? ( - - ) : null} - - - ); - } + return { + devtoolsHaveStarted, + connectionStatus, + statusMessage, + bootDevTools, + metroDevice, + containerRef, + stopDevtools, + }; +} + +export function Component() { + const instance = usePlugin(devicePlugin); + const connectionStatus = useValue(instance.connectionStatus); + const statusMessage = useValue(instance.statusMessage); + + useEffect(() => { + instance.bootDevTools(); + return instance.stopDevtools; + }, [instance]); + + return ( + + {!instance.devtoolsHaveStarted() ? ( + + + {(connectionStatus === ConnectionStatus.WaitingForReload && + instance.metroDevice?.ws) || + connectionStatus === ConnectionStatus.Error ? ( + + ) : null} + + + ) : null} + + + ); } diff --git a/desktop/plugins/public/reactdevtools/package.json b/desktop/plugins/public/reactdevtools/package.json index 8cb46319e..1534c220b 100644 --- a/desktop/plugins/public/reactdevtools/package.json +++ b/desktop/plugins/public/reactdevtools/package.json @@ -26,5 +26,8 @@ "bugs": { "email": "danielbuechele@fb.com" }, - "devDependencies": {} + "devDependencies": {}, + "peerDependencies": { + "flipper-plugin": "*" + } }