feat[devtools]: symbolicate source for inspected element (#28471)

Stacked on https://github.com/facebook/react/pull/28351, please review
only the last commit.

Top-level description of the approach:
1. Once user selects an element from the tree, frontend asks backend to
return the inspected element, this is where we simulate an error
happening in `render` function of the component and then we parse the
error stack. As an improvement, we should probably migrate from custom
implementation of error stack parser to `error-stack-parser` from npm.
2. When frontend receives the inspected element and this object is being
propagated, we create a Promise for symbolicated source, which is then
passed down to all components, which are using `source`.
3. These components use `use` hook for this promise and are wrapped in
Suspense.

Caching:
1. For browser extension, we cache Promises based on requested resource
+ key + column, also added use of
`chrome.devtools.inspectedWindow.getResource` API.
2. For standalone case (RN), we cache based on requested resource url,
we cache the content of it.
This commit is contained in:
Ruslan Lesiutin
2024-03-05 12:32:11 +00:00
committed by GitHub
parent c90d686ec0
commit 4a3916190b
24 changed files with 749 additions and 306 deletions
+7
View File
@@ -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',
},
},
],
+1 -1
View File
@@ -28,6 +28,6 @@
"react": "^17.0.0"
},
"dependencies": {
"error-stack-parser": "^2.0.2"
"error-stack-parser": "^2.1.4"
}
}
+35 -16
View File
@@ -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<string, string> = 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() {
@@ -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<string> {
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;
+8 -32
View File
@@ -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;
@@ -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,
@@ -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",
+3 -1
View File
@@ -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
@@ -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<Source | null> =
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 (
<div className={styles.InspectedElement}>
@@ -274,11 +258,20 @@ export default function InspectedElementWrapper(_: Props): React.Node {
{element.displayName}
</div>
</div>
{canOpenInEditor && (
<Button onClick={onOpenInEditor} title="Open in editor">
<ButtonIcon type="editor" />
</Button>
)}
{!!editorURL &&
inspectedElement != null &&
inspectedElement.source != null &&
symbolicatedSourcePromise != null && (
<React.Suspense fallback={<Skeleton height={16} width={24} />}>
<OpenInEditorButton
editorURL={editorURL}
source={inspectedElement.source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
</React.Suspense>
)}
{canToggleError && (
<Toggle
isChecked={isErrored}
@@ -317,13 +310,13 @@ export default function InspectedElementWrapper(_: Props): React.Node {
<ButtonIcon type="log-data" />
</Button>
)}
{!hideViewSourceAction && (
<Button
disabled={!canViewSource}
onClick={viewSource}
title="View source for this element">
<ButtonIcon type="view-source" />
</Button>
<InspectedElementViewSourceButton
canViewSource={inspectedElement?.canViewSource}
source={inspectedElement?.source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
)}
</div>
@@ -331,7 +324,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
<div className={styles.Loading}>Loading...</div>
)}
{inspectedElement !== null && (
{inspectedElement !== null && symbolicatedSourcePromise != null && (
<InspectedElementView
key={
inspectedElementID /* Force reset when selected Element changes */
@@ -341,6 +334,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
inspectedElement={inspectedElement}
parseHookNames={parseHookNames}
toggleParseHookNames={toggleParseHookNames}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
)}
</div>
@@ -174,17 +174,6 @@ export function InspectedElementContextController({
[setState, state],
);
const inspectedElementRef = useRef<null | InspectedElement>(null);
useEffect(() => {
if (
inspectedElement !== null &&
inspectedElement.hooks !== null &&
inspectedElementRef.current !== inspectedElement
) {
inspectedElementRef.current = inspectedElement;
}
}, [inspectedElement]);
useEffect(() => {
const purgeCachedMetadata = purgeCachedMetadataRef.current;
if (typeof purgeCachedMetadata === 'function') {
@@ -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;
}
@@ -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<InspectedElementSource | null>,
};
function InspectedElementSourcePanel({
source,
symbolicatedSourcePromise,
}: Props): React.Node {
return (
<div className={styles.Source} data-testname="InspectedElementView-Source">
<div className={styles.SourceHeaderRow}>
<div className={styles.SourceHeader}>source</div>
<React.Suspense fallback={<Skeleton height={16} width={16} />}>
<CopySourceButton
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
</React.Suspense>
</div>
<React.Suspense
fallback={
<div className={styles.SourceOneLiner}>
<Skeleton height={16} width="40%" />
</div>
}>
<FormattedSourceString
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
</React.Suspense>
</div>
);
}
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 (
<Button onClick={handleCopy} title="Copy to clipboard">
<ButtonIcon type="copy" />
</Button>
);
}
const {sourceURL, line, column} = symbolicatedSource;
const handleCopy = () => copy(`${sourceURL}:${line}:${column}`);
return (
<Button onClick={handleCopy} title="Copy to clipboard">
<ButtonIcon type="copy" />
</Button>
);
}
function FormattedSourceString({source, symbolicatedSourcePromise}: Props) {
const symbolicatedSource = React.use(symbolicatedSourcePromise);
if (symbolicatedSource == null) {
const {sourceURL, line} = source;
return (
<div className={styles.SourceOneLiner}>
{formatSourceForDisplay(sourceURL, line)}
</div>
);
}
const {sourceURL, line} = symbolicatedSource;
return (
<div className={styles.SourceOneLiner}>
{formatSourceForDisplay(sourceURL, line)}
</div>
);
}
// 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;
@@ -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;
}
}
@@ -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<string | number>) => void;
export type InspectPath = (path: Array<string | number>) => 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<Source | null>,
};
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({
</div>
)}
{source !== null && (
<Source sourceURL={source.sourceURL} line={source.line} />
{source != null && (
<InspectedElementSourcePanel
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
)}
</div>
@@ -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 (
<div className={styles.Source} data-testname="InspectedElementView-Source">
<div className={styles.SourceHeaderRow}>
<div className={styles.SourceHeader}>source</div>
<Button onClick={handleCopy} title="Copy to clipboard">
<ButtonIcon type="copy" />
</Button>
</div>
<div className={styles.SourceOneLiner}>
{formatSourceForDisplay(sourceURL, line)}
</div>
</div>
);
}
type OwnerViewProps = {
displayName: string,
hocDisplayNames: Array<string> | null,
@@ -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<InspectedElementSource | null> | null,
};
function InspectedElementViewSourceButton({
canViewSource,
source,
symbolicatedSourcePromise,
}: Props): React.Node {
const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
ViewElementSourceContext,
);
return (
<React.Suspense fallback={<Skeleton height={16} width={24} />}>
<ActualSourceButton
canViewSource={canViewSource}
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
canViewElementSourceFunction={canViewElementSourceFunction}
viewElementSourceFunction={viewElementSourceFunction}
/>
</React.Suspense>
);
}
type ActualSourceButtonProps = {
canViewSource: ?boolean,
source: ?InspectedElementSource,
symbolicatedSourcePromise: Promise<InspectedElementSource | null> | 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 (
<Button
disabled={!buttonIsEnabled}
onClick={viewSource}
title="View source for this element">
<ButtonIcon type="view-source" />
</Button>
);
}
export default InspectedElementViewSourceButton;
@@ -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<Source | null>,
};
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 (
<Button
disabled={shouldDisableButton}
onClick={() => window.open(url)}
title="Open in editor">
<ButtonIcon type="editor" />
</Button>
);
}
export default OpenInEditorButton;
@@ -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)
}
}
@@ -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 <div className={styles.root} style={{height, width}} />;
}
export default Skeleton;
@@ -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<Context> = createContext<Context>(
((null: any): Context),
);
ViewSourceContext.displayName = 'ViewSourceContext';
export default ViewSourceContext;
+52 -68
View File
@@ -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<string | number>,
) => 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}>
<ViewElementSourceContext.Provider value={viewElementSource}>
<ViewSourceContext.Provider value={viewSource}>
<HookNamesModuleLoaderContext.Provider
value={hookNamesModuleLoaderFunction || null}>
<FetchFileWithCachingContext.Provider
value={fetchFileWithCaching || null}>
<TreeContextController>
<ProfilerContextController>
<TimelineContextController>
<ThemeProvider>
<HookNamesModuleLoaderContext.Provider
value={hookNamesModuleLoaderFunction || null}>
<FetchFileWithCachingContext.Provider
value={fetchFileWithCaching || null}>
<TreeContextController>
<ProfilerContextController>
<TimelineContextController>
<ThemeProvider>
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
/>
</div>
)}
<div
className={styles.DevTools}
ref={devToolsRef}
data-react-devtools-portal-root={true}>
{showTabBar && (
<div className={styles.TabBar}>
<ReactLogo />
<span className={styles.DevToolsVersion}>
{process.env.DEVTOOLS_VERSION}
</span>
<div className={styles.Spacer} />
<TabBar
currentTab={tab}
id="DevTools"
selectTab={selectTab}
tabs={tabs}
type="navigation"
/>
</div>
)}
<div
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={
componentsPortalContainer
}
/>
</div>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={profilerPortalContainer}
/>
</div>
className={styles.TabContent}
hidden={tab !== 'components'}>
<Components
portalContainer={componentsPortalContainer}
/>
</div>
</ThemeProvider>
</TimelineContextController>
</ProfilerContextController>
</TreeContextController>
</FetchFileWithCachingContext.Provider>
</HookNamesModuleLoaderContext.Provider>
</ViewSourceContext.Provider>
<div
className={styles.TabContent}
hidden={tab !== 'profiler'}>
<Profiler
portalContainer={profilerPortalContainer}
/>
</div>
</div>
</ThemeProvider>
</TimelineContextController>
</ProfilerContextController>
</TreeContextController>
</FetchFileWithCachingContext.Provider>
</HookNamesModuleLoaderContext.Provider>
</ViewElementSourceContext.Provider>
</SettingsContextController>
<UnsupportedBridgeProtocolDialog />
@@ -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 (
<>
<div className={styles.Toolbar}>
@@ -65,17 +60,42 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
</div>
<ul className={styles.List}>
{stackToComponentSources(componentStack).map(
([displayName, source], index) => {
([displayName, stack], index) => {
if (stack == null) {
return (
<li key={index}>
<Button
className={styles.UnclickableSource}
disabled={true}>
{displayName}
</Button>
</li>
);
}
// 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 (
<li key={index}>
<Button
className={
source
canViewSource
? styles.ClickableSource
: styles.UnclickableSource
}
disabled={!source}
onClick={() => viewSource(source)}>
disabled={!canViewSource}
onClick={viewSource}>
{displayName}
</Button>
</li>
+122
View File
@@ -0,0 +1,122 @@
/**
* 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 {normalizeUrl} from 'react-devtools-shared/src/utils';
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<string, Promise<Source | null>> = new Map();
export async function symbolicateSourceWithCache(
fetchFileWithCaching: FetchFileWithCaching,
sourceURL: string,
line: number, // 1-based
column: number, // 1-based
): Promise<Source | null> {
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;
}
const SOURCE_MAP_ANNOTATION_PREFIX = 'sourceMappingURL=';
async function symbolicateSource(
fetchFileWithCaching: FetchFileWithCaching,
sourceURL: string,
lineNumber: number, // 1-based
columnNumber: number, // 1-based
): Promise<Source | null> {
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(SOURCE_MAP_ANNOTATION_PREFIX)) {
const sourceMapAnnotationStartIndex = resourceLine.indexOf(
SOURCE_MAP_ANNOTATION_PREFIX,
);
const sourceMapURL = resourceLine.slice(
sourceMapAnnotationStartIndex + SOURCE_MAP_ANNOTATION_PREFIX.length,
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, // 1-based
columnNumber, // 1-based
});
try {
void new URL(possiblyURL); // This is a valid URL
const normalizedURL = normalizeUrl(possiblyURL);
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;
}
+5
View File
@@ -955,3 +955,8 @@ export function backendToFrontendSerializedElementMapper(
compiledWithForget,
};
}
// This is a hacky one to just support this exact case.
export function normalizeUrl(url: string): string {
return url.replace('/./', '/');
}
+18 -1
View File
@@ -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"
@@ -10311,6 +10318,11 @@ jsbn@~0.1.0:
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
jsc-safe-url@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz#141c14fbb43791e88d5dc64e85a374575a83477a"
integrity sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==
jsdom@^20.0.0:
version "20.0.3"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db"
@@ -14385,6 +14397,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"