From c52df02f84f123a524259eac554f4d2407f1904c Mon Sep 17 00:00:00 2001 From: Tianyu Yao Date: Thu, 1 Sep 2022 12:39:03 -0700 Subject: [PATCH] Enable inspection from devtools Summary: Changelog: [General][Added] - Added an overlay similar to Inspector.js that allows directly selecting elements on RN from React DevTools This diff updates DevToolsHighlighter into DevToolsOverlay. It now also allows DevTools user to select an element to inspect directly from DevTools. Depends on https://github.com/facebook/react/pull/25111 to work. TODOs: - Currently once an element selected on RN, the inspector toggle isn't turned off automatically. - Fabric support depends on https://github.com/facebook/react/pull/25118 Reviewed By: lunaruan Differential Revision: D38815494 fbshipit-source-id: 7e1e3a78f6594960b5dfaec142bafd3ca4b146af --- Libraries/Inspector/DevtoolsHighlighter.js | 80 --------- Libraries/Inspector/DevtoolsOverlay.js | 167 ++++++++++++++++++ Libraries/Inspector/Inspector.js | 59 +------ .../getInspectorDataForViewAtPoint.js | 71 ++++++++ Libraries/ReactNative/AppContainer.js | 20 ++- 5 files changed, 254 insertions(+), 143 deletions(-) delete mode 100644 Libraries/Inspector/DevtoolsHighlighter.js create mode 100644 Libraries/Inspector/DevtoolsOverlay.js create mode 100644 Libraries/Inspector/getInspectorDataForViewAtPoint.js diff --git a/Libraries/Inspector/DevtoolsHighlighter.js b/Libraries/Inspector/DevtoolsHighlighter.js deleted file mode 100644 index c3d6f1c3a3fbc3..00000000000000 --- a/Libraries/Inspector/DevtoolsHighlighter.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 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 - * @flow - */ - -import ElementBox from './ElementBox'; -import * as React from 'react'; -const {useEffect, useState} = React; - -const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; - -export default function DevtoolsHighlighter(): React.Node { - const [inspected, setInspected] = useState(null); - useEffect(() => { - let devToolsAgent = null; - let hideTimeoutId = null; - - function onAgentHideNativeHighlight() { - // we wait to actually hide in order to avoid flicker - clearTimeout(hideTimeoutId); - hideTimeoutId = setTimeout(() => { - setInspected(null); - }, 100); - } - - function onAgentShowNativeHighlight(node: any) { - clearTimeout(hideTimeoutId); - // Shape of `node` is different in Fabric. - const component = node.canonical ?? node; - - component.measure((x, y, width, height, left, top) => { - setInspected({ - frame: {left, top, width, height}, - }); - }); - } - - function cleanup() { - const currentAgent = devToolsAgent; - if (currentAgent != null) { - currentAgent.removeListener( - 'hideNativeHighlight', - onAgentHideNativeHighlight, - ); - currentAgent.removeListener( - 'showNativeHighlight', - onAgentShowNativeHighlight, - ); - currentAgent.removeListener('shutdown', cleanup); - devToolsAgent = null; - } - } - - function _attachToDevtools(agent: Object) { - devToolsAgent = agent; - agent.addListener('hideNativeHighlight', onAgentHideNativeHighlight); - agent.addListener('showNativeHighlight', onAgentShowNativeHighlight); - agent.addListener('shutdown', cleanup); - } - - hook.on('react-devtools', _attachToDevtools); - if (hook.reactDevtoolsAgent) { - _attachToDevtools(hook.reactDevtoolsAgent); - } - return () => { - hook.off('react-devtools', _attachToDevtools); - cleanup(); - }; - }, []); - - if (inspected != null) { - return ; - } - return null; -} diff --git a/Libraries/Inspector/DevtoolsOverlay.js b/Libraries/Inspector/DevtoolsOverlay.js new file mode 100644 index 00000000000000..6df5593becb5f4 --- /dev/null +++ b/Libraries/Inspector/DevtoolsOverlay.js @@ -0,0 +1,167 @@ +/** + * 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 + * @flow + */ + +import ElementBox from './ElementBox'; +import * as React from 'react'; +import type {PressEvent} from '../Types/CoreEventTypes'; +import View from '../Components/View/View'; +import StyleSheet from '../StyleSheet/StyleSheet'; +import Dimensions from '../Utilities/Dimensions'; +const getInspectorDataForViewAtPoint = require('./getInspectorDataForViewAtPoint'); +const ReactNative = require('../Renderer/shims/ReactNative'); + +import type {HostRef} from './getInspectorDataForViewAtPoint'; + +const {useEffect, useState, useCallback, useRef} = React; + +const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + +export default function DevtoolsOverlay({ + inspectedView, +}: { + inspectedView: ?HostRef, +}): React.Node { + const [inspected, setInspected] = useState(null); + const [isInspecting, setIsInspecting] = useState(false); + const devToolsAgentRef = useRef(null); + + useEffect(() => { + let devToolsAgent = null; + let hideTimeoutId = null; + + function onAgentHideNativeHighlight() { + // we wait to actually hide in order to avoid flicker + clearTimeout(hideTimeoutId); + hideTimeoutId = setTimeout(() => { + setInspected(null); + }, 100); + } + + function onAgentShowNativeHighlight(node: any) { + clearTimeout(hideTimeoutId); + // Shape of `node` is different in Fabric. + const component = node.canonical ?? node; + + component.measure((x, y, width, height, left, top) => { + setInspected({ + frame: {left, top, width, height}, + }); + }); + } + + function cleanup() { + const currentAgent = devToolsAgent; + if (currentAgent != null) { + currentAgent.removeListener( + 'hideNativeHighlight', + onAgentHideNativeHighlight, + ); + currentAgent.removeListener( + 'showNativeHighlight', + onAgentShowNativeHighlight, + ); + currentAgent.removeListener('shutdown', cleanup); + currentAgent.removeListener( + 'startInspectingNative', + onStartInspectingNative, + ); + currentAgent.removeListener( + 'stopInspectingNative', + onStopInspectingNative, + ); + devToolsAgent = null; + } + devToolsAgentRef.current = null; + } + + function onStartInspectingNative() { + setIsInspecting(true); + } + + function onStopInspectingNative() { + setIsInspecting(false); + } + + function _attachToDevtools(agent: Object) { + devToolsAgent = agent; + devToolsAgentRef.current = agent; + agent.addListener('hideNativeHighlight', onAgentHideNativeHighlight); + agent.addListener('showNativeHighlight', onAgentShowNativeHighlight); + agent.addListener('shutdown', cleanup); + agent.addListener('startInspectingNative', onStartInspectingNative); + agent.addListener('stopInspectingNative', onStopInspectingNative); + } + + hook.on('react-devtools', _attachToDevtools); + if (hook.reactDevtoolsAgent) { + _attachToDevtools(hook.reactDevtoolsAgent); + } + return () => { + hook.off('react-devtools', _attachToDevtools); + cleanup(); + }; + }, []); + + const findViewForTouchEvent = useCallback( + (e: PressEvent) => { + const agent = devToolsAgentRef.current; + if (agent == null) { + return; + } + const {locationX, locationY} = e.nativeEvent.touches[0]; + getInspectorDataForViewAtPoint( + inspectedView, + locationX, + locationY, + viewData => { + const {touchedViewTag} = viewData; + if (touchedViewTag != null) { + agent.selectNode(ReactNative.findNodeHandle(touchedViewTag)); + return true; + } + return false; + }, + ); + }, + [inspectedView], + ); + + const shouldSetResponser = useCallback( + (e: PressEvent): boolean => { + findViewForTouchEvent(e); + return true; + }, + [findViewForTouchEvent], + ); + + let highlight = inspected ? : null; + if (isInspecting) { + return ( + + {highlight} + + ); + } + return highlight; +} + +const styles = StyleSheet.create({ + inspector: { + backgroundColor: 'transparent', + position: 'absolute', + left: 0, + top: 0, + right: 0, + }, +}); diff --git a/Libraries/Inspector/Inspector.js b/Libraries/Inspector/Inspector.js index 504c6963f5386a..ff2a2eda91c34d 100644 --- a/Libraries/Inspector/Inspector.js +++ b/Libraries/Inspector/Inspector.js @@ -20,70 +20,18 @@ const ReactNative = require('../Renderer/shims/ReactNative'); const StyleSheet = require('../StyleSheet/StyleSheet'); const View = require('../Components/View/View'); const ReactNativeStyleAttributes = require('../Components/View/ReactNativeStyleAttributes'); +const getInspectorDataForViewAtPoint = require('./getInspectorDataForViewAtPoint'); -const invariant = require('invariant'); - -import type { - HostComponent, - TouchedViewDataAtPoint, -} from '../Renderer/shims/ReactNativeTypes'; - -type HostRef = React.ElementRef>; - -export type ReactRenderer = { - rendererConfig: { - getInspectorDataForViewAtPoint: ( - inspectedView: ?HostRef, - locationX: number, - locationY: number, - callback: Function, - ) => void, - ... - }, -}; +import type {TouchedViewDataAtPoint} from '../Renderer/shims/ReactNativeTypes'; +import type {HostRef} from './getInspectorDataForViewAtPoint'; const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; -const renderers = findRenderers(); // Required for React DevTools to view/edit React Native styles in Flipper. // Flipper doesn't inject these values when initializing DevTools. hook.resolveRNStyle = require('../StyleSheet/flattenStyle'); hook.nativeStyleEditorValidAttributes = Object.keys(ReactNativeStyleAttributes); -function findRenderers(): $ReadOnlyArray { - const allRenderers = Array.from(hook.renderers.values()); - invariant( - allRenderers.length >= 1, - 'Expected to find at least one React Native renderer on DevTools hook.', - ); - return allRenderers; -} - -function getInspectorDataForViewAtPoint( - inspectedView: ?HostRef, - locationX: number, - locationY: number, - callback: (viewData: TouchedViewDataAtPoint) => void, -) { - // Check all renderers for inspector data. - for (let i = 0; i < renderers.length; i++) { - const renderer = renderers[i]; - if (renderer?.rendererConfig?.getInspectorDataForViewAtPoint != null) { - renderer.rendererConfig.getInspectorDataForViewAtPoint( - inspectedView, - locationX, - locationY, - viewData => { - // Only return with non-empty view data since only one renderer will have this view. - if (viewData && viewData.hierarchy.length > 0) { - callback(viewData); - } - }, - ); - } - } -} - class Inspector extends React.Component< { inspectedView: ?HostRef, @@ -221,6 +169,7 @@ class Inspector extends React.Component< this._setTouchedViewData(viewData); this._setTouchedViewData = null; } + return false; }, ); } diff --git a/Libraries/Inspector/getInspectorDataForViewAtPoint.js b/Libraries/Inspector/getInspectorDataForViewAtPoint.js new file mode 100644 index 00000000000000..94d2a915e9ebe4 --- /dev/null +++ b/Libraries/Inspector/getInspectorDataForViewAtPoint.js @@ -0,0 +1,71 @@ +/** + * 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 + * @flow + */ + +import type { + HostComponent, + TouchedViewDataAtPoint, +} from '../Renderer/shims/ReactNativeTypes'; + +export type HostRef = React.ElementRef>; +export type ReactRenderer = { + rendererConfig: { + getInspectorDataForViewAtPoint: ( + inspectedView: ?HostRef, + locationX: number, + locationY: number, + callback: Function, + ) => void, + ... + }, +}; + +const React = require('react'); +const invariant = require('invariant'); + +const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; +const renderers = findRenderers(); + +function findRenderers(): $ReadOnlyArray { + const allRenderers = Array.from(hook.renderers.values()); + invariant( + allRenderers.length >= 1, + 'Expected to find at least one React Native renderer on DevTools hook.', + ); + return allRenderers; +} + +module.exports = function getInspectorDataForViewAtPoint( + inspectedView: ?HostRef, + locationX: number, + locationY: number, + callback: (viewData: TouchedViewDataAtPoint) => boolean, +) { + let shouldBreak = false; + // Check all renderers for inspector data. + for (let i = 0; i < renderers.length; i++) { + if (shouldBreak) { + break; + } + const renderer = renderers[i]; + if (renderer?.rendererConfig?.getInspectorDataForViewAtPoint != null) { + renderer.rendererConfig.getInspectorDataForViewAtPoint( + inspectedView, + locationX, + locationY, + viewData => { + // Only return with non-empty view data since only one renderer will have this view. + if (viewData && viewData.hierarchy.length > 0) { + shouldBreak = callback(viewData); + } + }, + ); + } + } +}; diff --git a/Libraries/ReactNative/AppContainer.js b/Libraries/ReactNative/AppContainer.js index ccf04260152b34..c49cf04ced8520 100644 --- a/Libraries/ReactNative/AppContainer.js +++ b/Libraries/ReactNative/AppContainer.js @@ -29,6 +29,7 @@ type Props = $ReadOnly<{| type State = {| inspector: ?React.Node, + devtoolsOverlay: ?React.Node, mainKey: number, hasError: boolean, |}; @@ -36,6 +37,7 @@ type State = {| class AppContainer extends React.Component { state: State = { inspector: null, + devtoolsOverlay: null, mainKey: 1, hasError: false, }; @@ -65,6 +67,14 @@ class AppContainer extends React.Component { this.setState({inspector}); }, ); + if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__ != null) { + const DevtoolsOverlay = + require('../Inspector/DevtoolsOverlay').default; + const devtoolsOverlay = ( + + ); + this.setState({devtoolsOverlay}); + } } } } @@ -77,7 +87,6 @@ class AppContainer extends React.Component { render(): React.Node { let logBox = null; - let devtoolsHighlighter = null; if (__DEV__) { if (!global.__RCTProfileIsProfiling) { if (!this.props.internal_excludeLogBox) { @@ -85,17 +94,12 @@ class AppContainer extends React.Component { require('../LogBox/LogBoxNotificationContainer').default; logBox = ; } - if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__ != null) { - const DevtoolsHighlighter = - require('../Inspector/DevtoolsHighlighter').default; - devtoolsHighlighter = ; - } } } let innerView: React.Node = ( { {!this.state.hasError && innerView} - {devtoolsHighlighter} + {this.state.devtoolsOverlay} {this.state.inspector} {logBox}