From 4062950fbe148495ba01d3e15455706e40e8c630 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Wed, 12 May 2021 14:20:57 -0700 Subject: [PATCH] Convert React DevTools to Sandy Summary: Converted ReactDevTools to Sandy, no real semantic changes. Will add those in next diffs. Made some minor flow optimizations. The port forwarding setup interacted directly with the Flipper store, so made an escape hatch for that. Will clean that up again in next diffs. Reviewed By: passy Differential Revision: D28380055 fbshipit-source-id: 053979fd10bf3b62089a4f1e27b0e02b4b05e2e1 --- desktop/app/src/store.tsx | 5 + .../plugins/public/reactdevtools/index.tsx | 320 ++++++++---------- .../plugins/public/reactdevtools/package.json | 5 +- 3 files changed, 158 insertions(+), 172 deletions(-) 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": "*" + } }