From 2a1097f9ce32eb8ceb10ab63537661563d8d8a6a Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 28 Feb 2024 22:55:44 +0000 Subject: [PATCH] feat[devtools]: symbolicate source for inspected element --- .eslintrc.js | 7 + packages/react-debug-tools/package.json | 2 +- .../react-devtools-core/src/standalone.js | 51 ++++--- .../src/main/fetchFileWithCaching.js | 31 +++- .../src/main/index.js | 40 ++---- .../webpack.config.js | 2 + packages/react-devtools-shared/package.json | 1 + .../react-devtools-shared/src/backendAPI.js | 4 +- .../views/Components/InspectedElement.js | 100 +++++++------ .../Components/InspectedElementContext.js | 11 -- .../InspectedElementSourcePanel.css | 25 ++++ .../Components/InspectedElementSourcePanel.js | 132 ++++++++++++++++++ .../views/Components/InspectedElementView.css | 27 +--- .../views/Components/InspectedElementView.js | 62 ++------ .../InspectedElementViewSourceButton.js | 97 +++++++++++++ .../views/Components/OpenInEditorButton.js | 90 ++++++++++++ .../devtools/views/Components/Skeleton.css | 13 ++ .../src/devtools/views/Components/Skeleton.js | 23 +++ .../views/Components/ViewSourceContext.js | 25 ---- .../src/devtools/views/DevTools.js | 118 +++++++--------- .../views/Profiler/SidebarEventInfo.js | 46 ++++-- .../src/symbolicateSource.js | 122 ++++++++++++++++ packages/react-devtools-shared/src/utils.js | 5 + yarn.lock | 19 ++- 24 files changed, 748 insertions(+), 305 deletions(-) create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/Skeleton.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/Skeleton.js delete mode 100644 packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js create mode 100644 packages/react-devtools-shared/src/symbolicateSource.js diff --git a/.eslintrc.js b/.eslintrc.js index a048ec6d247da..7bb59ee17f0c6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -455,6 +455,13 @@ module.exports = { __IS_CHROME__: 'readonly', __IS_FIREFOX__: 'readonly', __IS_EDGE__: 'readonly', + __IS_INTERNAL_VERSION__: 'readonly', + }, + }, + { + files: ['packages/react-devtools-shared/**/*.js'], + globals: { + __IS_INTERNAL_VERSION__: 'readonly', }, }, ], diff --git a/packages/react-debug-tools/package.json b/packages/react-debug-tools/package.json index bb267fefbc012..dcd5e888ff920 100644 --- a/packages/react-debug-tools/package.json +++ b/packages/react-debug-tools/package.json @@ -28,6 +28,6 @@ "react": "^17.0.0" }, "dependencies": { - "error-stack-parser": "^2.0.2" + "error-stack-parser": "^2.1.4" } } diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index 9c55fe7d2c669..6829c27895d93 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -33,7 +33,7 @@ import { import {localStorageSetItem} from 'react-devtools-shared/src/storage'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; -import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; +import type {Source} from 'react-devtools-shared/src/shared/types'; installHook(window); @@ -127,36 +127,55 @@ function reload() { store: ((store: any): Store), warnIfLegacyBackendDetected: true, viewElementSourceFunction, + fetchFileWithCaching, }), ); }, 100); } +const resourceCache: Map = new Map(); + +// As a potential improvement, this should be done from the backend of RDT. +// Browser extension is doing this via exchanging messages +// between devtools_page and dedicated content script for it, see `fetchFileWithCaching.js`. +async function fetchFileWithCaching(url: string) { + if (resourceCache.has(url)) { + return Promise.resolve(resourceCache.get(url)); + } + + return fetch(url) + .then(data => data.text()) + .then(content => { + resourceCache.set(url, content); + + return content; + }); +} + function canViewElementSourceFunction( - inspectedElement: InspectedElement, + _source: Source, + symbolicatedSource: Source | null, ): boolean { - if ( - inspectedElement.canViewSource === false || - inspectedElement.source === null - ) { + if (symbolicatedSource == null) { return false; } - const {source} = inspectedElement; - - return doesFilePathExist(source.sourceURL, projectRoots); + return doesFilePathExist(symbolicatedSource.sourceURL, projectRoots); } function viewElementSourceFunction( - id: number, - inspectedElement: InspectedElement, + _source: Source, + symbolicatedSource: Source | null, ): void { - const {source} = inspectedElement; - if (source !== null) { - launchEditor(source.sourceURL, source.line, projectRoots); - } else { - log.error('Cannot inspect element', id); + if (symbolicatedSource == null) { + return; } + + launchEditor( + symbolicatedSource.sourceURL, + symbolicatedSource.line, + projectRoots, + ); } function onDisconnected() { diff --git a/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js b/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js index 6aee0969a3229..67725e27d8a7f 100644 --- a/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js +++ b/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js @@ -1,5 +1,6 @@ /* global chrome */ +import {normalizeUrl} from 'react-devtools-shared/src/utils'; import {__DEBUG__} from 'react-devtools-shared/src/constants'; let debugIDCounter = 0; @@ -107,17 +108,35 @@ const fetchFromPage = async (url, resolve, reject) => { }); }; -// Fetching files from the extension won't make use of the network cache -// for resources that have already been loaded by the page. -// This helper function allows the extension to request files to be fetched -// by the content script (running in the page) to increase the likelihood of a cache hit. -const fetchFileWithCaching = url => { +// 1. Check if resource is available via chrome.devtools.inspectedWindow.getResources +// 2. Check if resource was loaded previously and available in network cache via chrome.devtools.network.getHAR +// 3. Fallback to fetching directly from the page context (from backend) +async function fetchFileWithCaching(url: string): Promise { + if (__IS_CHROME__ || __IS_EDGE__) { + const resources = await new Promise(resolve => + chrome.devtools.inspectedWindow.getResources(r => resolve(r)), + ); + + const normalizedReferenceURL = normalizeUrl(url); + const resource = resources.find(r => r.url === normalizedReferenceURL); + + if (resource != null) { + const content = await new Promise(resolve => + resource.getContent(fetchedContent => resolve(fetchedContent)), + ); + + if (content) { + return content; + } + } + } + return new Promise((resolve, reject) => { // Try fetching from the Network cache first. // If DevTools was opened after the page started loading, we may have missed some requests. // So fall back to a fetch() from the page and hope we get a cached response that way. fetchFromNetworkCache(url, resolve, reject); }); -}; +} export default fetchFileWithCaching; diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index c93edc4adfb04..de817e268f5ee 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -128,34 +128,13 @@ function createBridgeAndStore() { } }; - const viewElementSourceFunction = id => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID != null) { - // Ask the renderer interface to determine the component function, - // and store it as a global variable on the window - bridge.send('viewElementSource', {id, rendererID}); + const viewElementSourceFunction = (source, symbolicatedSource) => { + const {sourceURL, line, column} = symbolicatedSource + ? symbolicatedSource + : source; - setTimeout(() => { - // Ask Chrome to display the location of the component function, - // or a render method if it is a Class (ideally Class instance, not type) - // assuming the renderer found one. - chrome.devtools.inspectedWindow.eval(` - if (window.$type != null) { - if ( - window.$type && - window.$type.prototype && - window.$type.prototype.isReactComponent - ) { - // inspect Component.render, not constructor - inspect(window.$type.prototype.render); - } else { - // inspect Functional Component - inspect(window.$type); - } - } - `); - }, 100); - } + // We use 1-based line and column, Chrome expects them 0-based. + chrome.devtools.panels.openResource(sourceURL, line - 1, column - 1); }; // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. @@ -183,17 +162,14 @@ function createBridgeAndStore() { store, warnIfUnsupportedVersionDetected: true, viewAttributeSourceFunction, + // Firefox doesn't support chrome.devtools.panels.openResource yet + canViewElementSourceFunction: () => __IS_CHROME__ || __IS_EDGE__, viewElementSourceFunction, - viewUrlSourceFunction, }), ); }; } -const viewUrlSourceFunction = (url, line, col) => { - chrome.devtools.panels.openResource(url, line, col); -}; - function ensureInitialHTMLIsCleared(container) { if (container._hasInitialHTMLBeenCleared) { return; diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 6d3e7de2a1031..917ee42594428 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -39,6 +39,7 @@ const LOGGING_URL = process.env.LOGGING_URL || null; const IS_CHROME = process.env.IS_CHROME === 'true'; const IS_FIREFOX = process.env.IS_FIREFOX === 'true'; const IS_EDGE = process.env.IS_EDGE === 'true'; +const IS_INTERNAL_VERSION = process.env.FEATURE_FLAG_TARGET === 'extension-fb'; const featureFlagTarget = process.env.FEATURE_FLAG_TARGET || 'extension-oss'; @@ -119,6 +120,7 @@ module.exports = { __IS_CHROME__: IS_CHROME, __IS_FIREFOX__: IS_FIREFOX, __IS_EDGE__: IS_EDGE, + __IS_INTERNAL_VERSION__: IS_INTERNAL_VERSION, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-extensions"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null, diff --git a/packages/react-devtools-shared/package.json b/packages/react-devtools-shared/package.json index c59c90230c586..e503f017fe71e 100644 --- a/packages/react-devtools-shared/package.json +++ b/packages/react-devtools-shared/package.json @@ -19,6 +19,7 @@ "@reach/tooltip": "^0.16.0", "clipboard-js": "^0.3.6", "compare-versions": "^5.0.3", + "jsc-safe-url": "^0.2.4", "json5": "^2.1.3", "local-storage-fallback": "^4.1.1", "lodash.throttle": "^4.1.1", diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index 6fcc35b574277..718b1cb55bf79 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -261,7 +261,9 @@ export function convertInspectedElementBackendToFrontend( rendererPackageName, rendererVersion, rootType, - source, + // Previous backend implementations (<= 5.0.1) have a different interface for Source, with fileName. + // This gates the source features for only compatible backends: >= 5.0.2 + source: source && source.sourceURL ? source : null, type, owners: owners === null diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index 3c7e1834c30b7..8688b132cb21b 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -15,7 +15,6 @@ import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import Icon from '../Icon'; import {ModalDialogContext} from '../ModalDialog'; -import ViewElementSourceContext from './ViewElementSourceContext'; import Toggle from '../Toggle'; import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types'; import CannotSuspendWarningMessage from './CannotSuspendWarningMessage'; @@ -23,10 +22,15 @@ import InspectedElementView from './InspectedElementView'; import {InspectedElementContext} from './InspectedElementContext'; import {getOpenInEditorURL} from '../../../utils'; import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants'; +import FetchFileWithCachingContext from './FetchFileWithCachingContext'; +import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource'; +import OpenInEditorButton from './OpenInEditorButton'; +import InspectedElementViewSourceButton from './InspectedElementViewSourceButton'; +import Skeleton from './Skeleton'; import styles from './InspectedElement.css'; -import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; +import type {Source} from 'react-devtools-shared/src/shared/types'; export type Props = {}; @@ -35,9 +39,6 @@ export type Props = {}; export default function InspectedElementWrapper(_: Props): React.Node { const {inspectedElementID} = useContext(TreeStateContext); const dispatch = useContext(TreeDispatcherContext); - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext( - ViewElementSourceContext, - ); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const { @@ -51,6 +52,25 @@ export default function InspectedElementWrapper(_: Props): React.Node { const {hookNames, inspectedElement, parseHookNames, toggleParseHookNames} = useContext(InspectedElementContext); + const fetchFileWithCaching = useContext(FetchFileWithCachingContext); + + const symbolicatedSourcePromise: null | Promise = + React.useMemo(() => { + if (inspectedElement == null) return null; + if (fetchFileWithCaching == null) return Promise.resolve(null); + + const {source} = inspectedElement; + if (source == null) return Promise.resolve(null); + + const {sourceURL, line, column} = source; + return symbolicateSourceWithCache( + fetchFileWithCaching, + sourceURL, + line, + column, + ); + }, [inspectedElement]); + const element = inspectedElementID !== null ? store.getElementByID(inspectedElementID) @@ -84,24 +104,6 @@ export default function InspectedElementWrapper(_: Props): React.Node { } }, [bridge, inspectedElementID, store]); - const viewSource = useCallback(() => { - if (viewElementSourceFunction != null && inspectedElement !== null) { - viewElementSourceFunction( - inspectedElement.id, - ((inspectedElement: any): InspectedElement), - ); - } - }, [inspectedElement, viewElementSourceFunction]); - - // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source. - // To detect this case, we defer to an injected helper function (if present). - const canViewSource = - inspectedElement !== null && - inspectedElement.canViewSource && - viewElementSourceFunction !== null && - (canViewElementSourceFunction === null || - canViewElementSourceFunction(inspectedElement)); - const isErrored = inspectedElement != null && inspectedElement.isErrored; const targetErrorBoundaryID = inspectedElement != null ? inspectedElement.targetErrorBoundaryID : null; @@ -134,9 +136,6 @@ export default function InspectedElementWrapper(_: Props): React.Node { }, ); - const canOpenInEditor = - editorURL && inspectedElement != null && inspectedElement.source != null; - const toggleErrored = useCallback(() => { if (inspectedElement == null || targetErrorBoundaryID == null) { return; @@ -212,21 +211,6 @@ export default function InspectedElementWrapper(_: Props): React.Node { } }, [bridge, dispatch, element, isSuspended, modalDialogDispatch, store]); - const onOpenInEditor = useCallback(() => { - const source = inspectedElement?.source; - if (source == null || editorURL == null) { - return; - } - - const url = new URL(editorURL); - url.href = url.href - .replace('{path}', source.sourceURL) - .replace('{line}', String(source.line)) - .replace('%7Bpath%7D', source.sourceURL) - .replace('%7Bline%7D', String(source.line)); - window.open(url); - }, [inspectedElement, editorURL]); - if (element === null) { return (
@@ -274,11 +258,20 @@ export default function InspectedElementWrapper(_: Props): React.Node { {element.displayName}
- {canOpenInEditor && ( - - )} + + {!!editorURL && + inspectedElement != null && + inspectedElement.source != null && + symbolicatedSourcePromise != null && ( + }> + + + )} + {canToggleError && ( )} + {!hideViewSourceAction && ( - + )} @@ -331,7 +324,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
Loading...
)} - {inspectedElement !== null && ( + {inspectedElement !== null && symbolicatedSourcePromise != null && ( )} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index 601c8c9615a16..0e198fc5b7999 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -174,17 +174,6 @@ export function InspectedElementContextController({ [setState, state], ); - const inspectedElementRef = useRef(null); - useEffect(() => { - if ( - inspectedElement !== null && - inspectedElement.hooks !== null && - inspectedElementRef.current !== inspectedElement - ) { - inspectedElementRef.current = inspectedElement; - } - }, [inspectedElement]); - useEffect(() => { const purgeCachedMetadata = purgeCachedMetadataRef.current; if (typeof purgeCachedMetadata === 'function') { diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.css new file mode 100644 index 0000000000000..0fab548c8c457 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.css @@ -0,0 +1,25 @@ +.Source { + padding: 0.25rem; + border-top: 1px solid var(--color-border); +} + +.SourceHeaderRow { + display: flex; + align-items: center; + min-height: 24px; +} + +.SourceHeader { + flex: 1 1; + font-family: var(--font-family-sans); +} + +.SourceOneLiner { + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + margin-left: 1rem; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js new file mode 100644 index 0000000000000..b051e94ee20cc --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js @@ -0,0 +1,132 @@ +/** + * 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. + * + * @flow + */ + +import * as React from 'react'; +import {copy} from 'clipboard-js'; +import {toNormalUrl} from 'jsc-safe-url'; + +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import Skeleton from './Skeleton'; + +import type {Source as InspectedElementSource} from 'react-devtools-shared/src/shared/types'; +import styles from './InspectedElementSourcePanel.css'; + +type Props = { + source: InspectedElementSource, + symbolicatedSourcePromise: Promise, +}; + +function InspectedElementSourcePanel({ + source, + symbolicatedSourcePromise, +}: Props): React.Node { + return ( +
+
+
source
+ + }> + + +
+ + + +
+ }> + + + + ); +} + +function CopySourceButton({source, symbolicatedSourcePromise}: Props) { + const symbolicatedSource = React.use(symbolicatedSourcePromise); + if (symbolicatedSource == null) { + const {sourceURL, line, column} = source; + const handleCopy = () => copy(`${sourceURL}:${line}:${column}`); + + return ( + + ); + } + + const {sourceURL, line, column} = symbolicatedSource; + const handleCopy = () => copy(`${sourceURL}:${line}:${column}`); + + return ( + + ); +} + +function FormattedSourceString({source, symbolicatedSourcePromise}: Props) { + const symbolicatedSource = React.use(symbolicatedSourcePromise); + if (symbolicatedSource == null) { + const {sourceURL, line} = source; + + return ( +
+ {formatSourceForDisplay(sourceURL, line)} +
+ ); + } + + const {sourceURL, line} = symbolicatedSource; + + return ( +
+ {formatSourceForDisplay(sourceURL, line)} +
+ ); +} + +// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame +function formatSourceForDisplay(sourceURL: string, line: number) { + // Metro can return JSC-safe URLs, which have `//&` as a delimiter + // https://www.npmjs.com/package/jsc-safe-url + const sanitizedSourceURL = sourceURL.includes('//&') + ? toNormalUrl(sourceURL) + : sourceURL; + + // Note: this RegExp doesn't work well with URLs from Metro, + // which provides bundle URL with query parameters prefixed with /& + const BEFORE_SLASH_RE = /^(.*)[\\\/]/; + + let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, ''); + + // In DEV, include code for a common special case: + // prefer "folder/index.js" instead of just "index.js". + if (/^index\./.test(nameOnly)) { + const match = sanitizedSourceURL.match(BEFORE_SLASH_RE); + if (match) { + const pathBeforeSlash = match[1]; + if (pathBeforeSlash) { + const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, ''); + nameOnly = folderName + '/' + nameOnly; + } + } + } + + return `${nameOnly}:${line}`; +} + +export default InspectedElementSourcePanel; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css index cb83da8f45b32..ce1bf1e81c4c5 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css @@ -7,31 +7,6 @@ font-family: var(--font-family-sans); } -.Source { - padding: 0.25rem; - border-top: 1px solid var(--color-border); -} - -.SourceHeaderRow { - display: flex; - align-items: center; -} - -.SourceHeader { - flex: 1 1; - font-family: var(--font-family-sans); -} - -.SourceOneLiner { - font-family: var(--font-family-monospace); - font-size: var(--font-size-monospace-normal); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; - margin-left: 1rem; -} - .Owner { color: var(--color-component-name); font-family: var(--font-family-monospace); @@ -94,4 +69,4 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} \ No newline at end of file +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js index ff7a046f878b2..1c8f5864a823a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js @@ -7,7 +7,6 @@ * @flow */ -import {copy} from 'clipboard-js'; import * as React from 'react'; import {Fragment, useCallback, useContext} from 'react'; import {TreeDispatcherContext} from './TreeContext'; @@ -15,7 +14,6 @@ import {BridgeContext, ContextMenuContext, StoreContext} from '../context'; import ContextMenu from '../../ContextMenu/ContextMenu'; import ContextMenuItem from '../../ContextMenu/ContextMenuItem'; import Button from '../Button'; -import ButtonIcon from '../ButtonIcon'; import Icon from '../Icon'; import InspectedElementBadges from './InspectedElementBadges'; import InspectedElementContextTree from './InspectedElementContextTree'; @@ -34,6 +32,7 @@ import { } from 'react-devtools-shared/src/backendAPI'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import {logEvent} from 'react-devtools-shared/src/Logger'; +import InspectedElementSourcePanel from './InspectedElementSourcePanel'; import styles from './InspectedElementView.css'; @@ -44,9 +43,7 @@ import type { } from 'react-devtools-shared/src/frontend/types'; import type {HookNames} from 'react-devtools-shared/src/frontend/types'; import type {ToggleParseHookNames} from './InspectedElementContext'; - -export type CopyPath = (path: Array) => void; -export type InspectPath = (path: Array) => void; +import type {Source} from 'react-devtools-shared/src/shared/types'; type Props = { element: Element, @@ -54,6 +51,7 @@ type Props = { inspectedElement: InspectedElement, parseHookNames: boolean, toggleParseHookNames: ToggleParseHookNames, + symbolicatedSourcePromise: Promise, }; export default function InspectedElementView({ @@ -62,6 +60,7 @@ export default function InspectedElementView({ inspectedElement, parseHookNames, toggleParseHookNames, + symbolicatedSourcePromise, }: Props): React.Node { const {id} = element; const {owners, rendererPackageName, rendererVersion, rootType, source} = @@ -171,8 +170,11 @@ export default function InspectedElementView({ )} - {source !== null && ( - + {source != null && ( + )} @@ -238,52 +240,6 @@ export default function InspectedElementView({ ); } -// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame -function formatSourceForDisplay(sourceURL: string, line: number) { - // Note: this RegExp doesn't work well with URLs from Metro, - // which provides bundle URL with query parameters prefixed with /& - const BEFORE_SLASH_RE = /^(.*)[\\\/]/; - - let nameOnly = sourceURL.replace(BEFORE_SLASH_RE, ''); - - // In DEV, include code for a common special case: - // prefer "folder/index.js" instead of just "index.js". - if (/^index\./.test(nameOnly)) { - const match = sourceURL.match(BEFORE_SLASH_RE); - if (match) { - const pathBeforeSlash = match[1]; - if (pathBeforeSlash) { - const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, ''); - nameOnly = folderName + '/' + nameOnly; - } - } - } - - return `${nameOnly}:${sourceURL}`; -} - -type SourceProps = { - sourceURL: string, - line: number, -}; - -function Source({sourceURL, line}: SourceProps) { - const handleCopy = () => copy(`${sourceURL}:${line}`); - return ( -
-
-
source
- -
-
- {formatSourceForDisplay(sourceURL, line)} -
-
- ); -} - type OwnerViewProps = { displayName: string, hocDisplayNames: Array | null, diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js new file mode 100644 index 0000000000000..31c6d1e557771 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js @@ -0,0 +1,97 @@ +/** + * 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. + * + * @flow + */ + +import * as React from 'react'; + +import ButtonIcon from '../ButtonIcon'; +import Button from '../Button'; +import ViewElementSourceContext from './ViewElementSourceContext'; +import Skeleton from './Skeleton'; + +import type {Source as InspectedElementSource} from 'react-devtools-shared/src/shared/types'; +import type { + CanViewElementSource, + ViewElementSource, +} from 'react-devtools-shared/src/devtools/views/DevTools'; + +const {useCallback, useContext} = React; + +type Props = { + canViewSource: ?boolean, + source: ?InspectedElementSource, + symbolicatedSourcePromise: Promise | null, +}; + +function InspectedElementViewSourceButton({ + canViewSource, + source, + symbolicatedSourcePromise, +}: Props): React.Node { + const {canViewElementSourceFunction, viewElementSourceFunction} = useContext( + ViewElementSourceContext, + ); + + return ( + }> + + + ); +} + +type ActualSourceButtonProps = { + canViewSource: ?boolean, + source: ?InspectedElementSource, + symbolicatedSourcePromise: Promise | null, + canViewElementSourceFunction: CanViewElementSource | null, + viewElementSourceFunction: ViewElementSource | null, +}; +function ActualSourceButton({ + canViewSource, + source, + symbolicatedSourcePromise, + canViewElementSourceFunction, + viewElementSourceFunction, +}: ActualSourceButtonProps): React.Node { + const symbolicatedSource = + symbolicatedSourcePromise == null + ? null + : React.use(symbolicatedSourcePromise); + + // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source. + // To detect this case, we defer to an injected helper function (if present). + const buttonIsEnabled = + !!canViewSource && + viewElementSourceFunction != null && + source != null && + (canViewElementSourceFunction == null || + canViewElementSourceFunction(source, symbolicatedSource)); + + const viewSource = useCallback(() => { + if (viewElementSourceFunction != null && source != null) { + viewElementSourceFunction(source, symbolicatedSource); + } + }, [source, symbolicatedSource]); + + return ( + + ); +} + +export default InspectedElementViewSourceButton; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js b/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js new file mode 100644 index 0000000000000..c63e8319b83f8 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js @@ -0,0 +1,90 @@ +/** + * 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. + * + */ + +import * as React from 'react'; + +import Button from 'react-devtools-shared/src/devtools/views/Button'; +import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon'; + +import type {Source} from 'react-devtools-shared/src/shared/types'; + +type Props = { + editorURL: string, + source: Source, + symbolicatedSourcePromise: Promise, +}; + +function checkConditions( + editorURL: string, + source: Source, +): {url: URL | null, shouldDisableButton: boolean} { + try { + const url = new URL(editorURL); + + let sourceURL = source.sourceURL; + + // Check if sourceURL is a correct URL, which has a protocol specified + if (sourceURL.includes('://')) { + if (!__IS_INTERNAL_VERSION__) { + // In this case, we can't really determine the path to a file, disable a button + return {url: null, shouldDisableButton: true}; + } else { + const endOfSourceMapURLPattern = '.js/'; + const endOfSourceMapURLIndex = sourceURL.lastIndexOf( + endOfSourceMapURLPattern, + ); + + if (endOfSourceMapURLIndex === -1) { + return {url: null, shouldDisableButton: true}; + } else { + sourceURL = sourceURL.slice( + endOfSourceMapURLIndex + endOfSourceMapURLPattern.length, + sourceURL.length, + ); + } + } + } + + const lineNumberAsString = String(source.line); + + url.href = url.href + .replace('{path}', sourceURL) + .replace('{line}', lineNumberAsString) + .replace('%7Bpath%7D', sourceURL) + .replace('%7Bline%7D', lineNumberAsString); + + return {url, shouldDisableButton: false}; + } catch (e) { + // User has provided incorrect editor url + return {url: null, shouldDisableButton: true}; + } +} + +function OpenInEditorButton({ + editorURL, + source, + symbolicatedSourcePromise, +}: Props): React.Node { + const symbolicatedSource = React.use(symbolicatedSourcePromise); + + const {url, shouldDisableButton} = checkConditions( + editorURL, + symbolicatedSource ? symbolicatedSource : source, + ); + + return ( + + ); +} + +export default OpenInEditorButton; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Skeleton.css b/packages/react-devtools-shared/src/devtools/views/Components/Skeleton.css new file mode 100644 index 0000000000000..70dcbe0c19ae3 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/Skeleton.css @@ -0,0 +1,13 @@ +.root { + border-radius: 0.25rem; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { + background-color: var(--color-dim); + } + 50% { + background-color: var(--color-dimmest) + } +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Skeleton.js b/packages/react-devtools-shared/src/devtools/views/Components/Skeleton.js new file mode 100644 index 0000000000000..c80e02274965c --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/Skeleton.js @@ -0,0 +1,23 @@ +/** + * 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. + * + * @flow + */ + +import * as React from 'react'; + +import styles from './Skeleton.css'; + +type Props = { + height: number | string, + width: number | string, +}; + +function Skeleton({height, width}: Props): React.Node { + return
; +} + +export default Skeleton; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js b/packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js deleted file mode 100644 index 74d3e15baec03..0000000000000 --- a/packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js +++ /dev/null @@ -1,25 +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. - * - * @flow - */ - -import type {ReactContext} from 'shared/ReactTypes'; - -import {createContext} from 'react'; - -import type {ViewUrlSource} from 'react-devtools-shared/src/devtools/views/DevTools'; - -export type Context = { - viewUrlSourceFunction: ViewUrlSource | null, -}; - -const ViewSourceContext: ReactContext = createContext( - ((null: any): Context), -); -ViewSourceContext.displayName = 'ViewSourceContext'; - -export default ViewSourceContext; diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index 934ebd6928bd1..9bee64ff95150 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -27,7 +27,6 @@ import TabBar from './TabBar'; import {SettingsContextController} from './Settings/SettingsContext'; import {TreeContextController} from './Components/TreeContext'; import ViewElementSourceContext from './Components/ViewElementSourceContext'; -import ViewSourceContext from './Components/ViewSourceContext'; import FetchFileWithCachingContext from './Components/FetchFileWithCachingContext'; import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import {ProfilerContextController} from './Profiler/ProfilerContext'; @@ -46,25 +45,25 @@ import styles from './DevTools.css'; import './root.css'; -import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; import type {FetchFileWithCaching} from './Components/FetchFileWithCachingContext'; import type {HookNamesModuleLoaderFunction} from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types'; +import type {Source} from 'react-devtools-shared/src/shared/types'; export type TabID = 'components' | 'profiler'; export type ViewElementSource = ( - id: number, - inspectedElement: InspectedElement, + source: Source, + symbolicatedSource: Source | null, ) => void; -export type ViewUrlSource = (url: string, row: number, column: number) => void; export type ViewAttributeSource = ( id: number, path: Array, ) => void; export type CanViewElementSource = ( - inspectedElement: InspectedElement, + source: Source, + symbolicatedSource: Source | null, ) => boolean; export type Props = { @@ -79,7 +78,6 @@ export type Props = { warnIfUnsupportedVersionDetected?: boolean, viewAttributeSourceFunction?: ?ViewAttributeSource, viewElementSourceFunction?: ?ViewElementSource, - viewUrlSourceFunction?: ?ViewUrlSource, readOnly?: boolean, hideSettings?: boolean, hideToggleErrorAction?: boolean, @@ -139,7 +137,6 @@ export default function DevTools({ warnIfUnsupportedVersionDetected = false, viewAttributeSourceFunction, viewElementSourceFunction, - viewUrlSourceFunction, readOnly, hideSettings, hideToggleErrorAction, @@ -203,15 +200,6 @@ export default function DevTools({ [canViewElementSourceFunction, viewElementSourceFunction], ); - const viewSource = useMemo( - () => ({ - viewUrlSourceFunction: viewUrlSourceFunction || null, - // todo(blakef): Add inspect(...) method here and remove viewElementSource - // to consolidate source code inspection. - }), - [viewUrlSourceFunction], - ); - const contextMenu = useMemo( () => ({ isEnabledForInspectedElement: enabledInspectedElementContextMenu, @@ -281,59 +269,55 @@ export default function DevTools({ componentsPortalContainer={componentsPortalContainer} profilerPortalContainer={profilerPortalContainer}> - - - - - - - -
- {showTabBar && ( -
- - - {process.env.DEVTOOLS_VERSION} - -
- -
- )} - -