mirror of
https://github.com/hanzoai/react.git
synced 2026-09-24 17:02:27 +00:00
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:
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,6 @@
|
||||
"react": "^17.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"error-stack-parser": "^2.0.2"
|
||||
"error-stack-parser": "^2.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-16
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+47
-53
@@ -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>
|
||||
|
||||
-11
@@ -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') {
|
||||
|
||||
+25
@@ -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;
|
||||
}
|
||||
Vendored
+132
@@ -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;
|
||||
+1
-26
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-53
@@ -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,
|
||||
|
||||
Vendored
+97
@@ -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;
|
||||
+90
@@ -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;
|
||||
-25
@@ -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
@@ -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 />
|
||||
|
||||
+33
-13
@@ -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>
|
||||
|
||||
@@ -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
@@ -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('/./', '/');
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user