From 4f61d6f39eb8adaa898457426e639bf08adb0738 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 | 39 ++---- .../webpack.config.js | 2 + .../src/backend/utils.js | 2 +- .../react-devtools-shared/src/backendAPI.js | 4 +- .../views/Components/InspectedElement.js | 100 +++++++-------- .../Components/InspectedElementContext.js | 11 -- .../InspectedElementSourcePanel.css | 25 ++++ .../Components/InspectedElementSourcePanel.js | 119 ++++++++++++++++++ .../views/Components/InspectedElementView.css | 27 +--- .../views/Components/InspectedElementView.js | 62 ++------- .../InspectedElementViewSourceButton.js | 100 +++++++++++++++ .../views/Components/OpenInEditorButton.js | 88 +++++++++++++ .../views/Components/ViewSourceContext.js | 25 ---- .../src/devtools/views/DevTools.js | 24 +--- .../views/Profiler/SidebarEventInfo.js | 46 +++++-- .../src/symbolicateSource.js | 119 ++++++++++++++++++ yarn.lock | 14 ++- 21 files changed, 640 insertions(+), 258 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 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 eaad9393c5685..9fbeba78c8e9f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -448,6 +448,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 f3ee5806f722a..2b0697ae56a1d 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..71ac3430e514b 100644 --- a/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js +++ b/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js @@ -107,17 +107,36 @@ 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)), + ); + + // This is a hacky way to make it work for Next, since their URLs are not normalized + const normalizedReferenceURL = url.replace('/./', '/'); + 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..a93597d97dca8 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -128,34 +128,12 @@ 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); - } + chrome.devtools.panels.openResource(sourceURL, line, column); }; // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. @@ -183,17 +161,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/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index 5655fa0bed0f2..d413e86616d1b 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -297,7 +297,7 @@ export function parseSourceFromComponentStack( const frames = componentStack.split('\n'); // eslint-disable-next-line no-for-of-loops/no-for-of-loops for (const frame of frames) { - const openingBracketIndex = frame.lastIndexOf('('); + const openingBracketIndex = frame.indexOf('('); if (openingBracketIndex === -1) continue; const closingBracketIndex = frame.lastIndexOf(')'); if ( 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..5b1321f9743ad 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,14 @@ 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 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 +38,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 +51,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 +103,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 +135,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 +210,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 +257,21 @@ export default function InspectedElementWrapper(_: Props): React.Node { {element.displayName}
- {canOpenInEditor && ( - - )} + + {!!editorURL && + inspectedElement != null && + inspectedElement.source != null && + symbolicatedSourcePromise != null && ( + // TODO: update fallback + Loading...}> + + + )} + {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..e7b66206ce9f1 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js @@ -0,0 +1,119 @@ +/** + * 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 Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; + +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
+ + Loading...
}> + + +
+ + Loading...}> + + + + ); +} + +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) { + // 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}:${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..ba160c94ad048 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js @@ -0,0 +1,100 @@ +/** + * 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 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 ( + }> + + + ); +} + +function Fallback(): React.Node { + return
Loading...
; +} + +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..0d57c7548f2bf --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js @@ -0,0 +1,88 @@ +/** + * 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 = '.pkg.js/'; + const endOfSourceMapURLIndex = sourceURL.lastIndexOf( + endOfSourceMapURLPattern, + ); + + if (endOfSourceMapURLIndex !== -1) { + 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/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..e5b06dfaa1de9 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,7 +269,6 @@ export default function DevTools({ componentsPortalContainer={componentsPortalContainer} profilerPortalContainer={profilerPortalContainer}> - - diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js index d0d8be666ed32..6797d5274929f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js @@ -7,13 +7,12 @@ * @flow */ -import type {Stack} from '../../utils'; import type {SchedulingEvent} from 'react-devtools-timeline/src/types'; import * as React from 'react'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; -import ViewSourceContext from '../Components/ViewSourceContext'; +import ViewElementSourceContext from '../Components/ViewElementSourceContext'; import {useContext} from 'react'; import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext'; import { @@ -32,16 +31,12 @@ type SchedulingEventProps = { }; function SchedulingEventInfo({eventInfo}: SchedulingEventProps) { - const {viewUrlSourceFunction} = useContext(ViewSourceContext); + const {canViewElementSourceFunction, viewElementSourceFunction} = useContext( + ViewElementSourceContext, + ); const {componentName, timestamp} = eventInfo; const componentStack = eventInfo.componentStack || null; - const viewSource = (source: ?Stack) => { - if (viewUrlSourceFunction != null && source != null) { - viewUrlSourceFunction(...source); - } - }; - return ( <>
@@ -65,17 +60,42 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
    {stackToComponentSources(componentStack).map( - ([displayName, source], index) => { + ([displayName, stack], index) => { + if (stack == null) { + return ( +
  • + +
  • + ); + } + + // TODO: We should support symbolication here as well, but + // symbolicating the whole stack can be expensive + const [sourceURL, line, column] = stack; + const source = {sourceURL, line, column}; + const canViewSource = + canViewElementSourceFunction == null || + canViewElementSourceFunction(source, null); + + const viewSource = + !canViewSource || viewElementSourceFunction == null + ? () => null + : () => viewElementSourceFunction(source, null); + return (
  • diff --git a/packages/react-devtools-shared/src/symbolicateSource.js b/packages/react-devtools-shared/src/symbolicateSource.js new file mode 100644 index 0000000000000..2302d4f18e941 --- /dev/null +++ b/packages/react-devtools-shared/src/symbolicateSource.js @@ -0,0 +1,119 @@ +/** + * 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 SourceMapConsumer from 'react-devtools-shared/src/hooks/SourceMapConsumer'; + +import type {Source} from 'react-devtools-shared/src/shared/types'; +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; + +const symbolicationCache: Map> = new Map(); + +export async function symbolicateSourceWithCache( + fetchFileWithCaching: FetchFileWithCaching, + sourceURL: string, + line: number, + column: number, +): Promise { + const key = `${sourceURL}:${line}:${column}`; + const cachedPromise = symbolicationCache.get(key); + if (cachedPromise != null) { + return cachedPromise; + } + + const promise = symbolicateSource( + fetchFileWithCaching, + sourceURL, + line, + column, + ); + symbolicationCache.set(key, promise); + + return promise; +} + +async function symbolicateSource( + fetchFileWithCaching: FetchFileWithCaching, + sourceURL: string, + lineNumber: number, + columnNumber: number, +): Promise { + const resource = await fetchFileWithCaching(sourceURL).catch(() => null); + if (resource == null) { + return null; + } + + const resourceLines = resource.split(/[\r\n]+/); + for (let i = resourceLines.length - 1; i >= 0; --i) { + const resourceLine = resourceLines[i]; + + // In case there is empty last line + if (!resourceLine) continue; + // Not an annotation? Stop looking for a source mapping url. + if (!resourceLine.startsWith('//#')) break; + + if (resourceLine.includes('sourceMappingURL=')) { + const sourceMapAnnotationStartIndex = + resourceLine.indexOf('sourceMappingURL='); + const sourceMapURL = resourceLine.slice( + sourceMapAnnotationStartIndex + 17, + resourceLine.length, + ); + + const sourceMap = await fetchFileWithCaching(sourceMapURL).catch( + () => null, + ); + if (sourceMap != null) { + try { + const parsedSourceMap = JSON.parse(sourceMap); + const consumer = SourceMapConsumer(parsedSourceMap); + const { + sourceURL: possiblyURL, + line, + column, + } = consumer.originalPositionFor({ + lineNumber, + columnNumber, + }); + + try { + void new URL(possiblyURL); // This is a valid URL + const normalizedURL = possiblyURL.replace('/./', '/'); + + return {sourceURL: normalizedURL, line, column}; + } catch (e) { + // This is not valid URL + if (possiblyURL.startsWith('/')) { + // This is an absolute path + return {sourceURL: possiblyURL, line, column}; + } + + // This is a relative path + const [sourceMapAbsolutePathWithoutQueryParameters] = + sourceMapURL.split(/[?#&]/); + + const absoluteSourcePath = + sourceMapAbsolutePathWithoutQueryParameters + + (sourceMapAbsolutePathWithoutQueryParameters.endsWith('/') + ? '' + : '/') + + possiblyURL; + + return {sourceURL: absoluteSourcePath, line, column}; + } + } catch (e) { + return null; + } + } + + return null; + } + } + + return null; +} diff --git a/yarn.lock b/yarn.lock index 6d0b74396361c..2660c79bcbaf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6789,13 +6789,20 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -error-stack-parser@^2.0.2, error-stack-parser@^2.0.6: +error-stack-parser@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8" integrity sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ== dependencies: stackframe "^1.1.1" +error-stack-parser@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.1.4.tgz#229cb01cdbfa84440bfa91876285b94680188286" + integrity sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ== + dependencies: + stackframe "^1.3.4" + es-abstract@^1.13.0: version "1.13.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" @@ -14385,6 +14392,11 @@ stackframe@^1.1.1: resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.1.1.tgz#ffef0a3318b1b60c3b58564989aca5660729ec71" integrity sha512-0PlYhdKh6AfFxRyK/v+6/k+/mMfyiEBbTM5L94D0ZytQnJ166wuwoTYLHFWGbs2dpA8Rgq763KGWmN1EQEYHRQ== +stackframe@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310" + integrity sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw== + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"