@samitouri / QOS-React-2 / commits / e5287287aa

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.

Ruslan Lesiutin committed Mar 5, 2024 at 12:32 UTC e5287287aacd3c51d24e223651b7f38ece584c49
24 files changed +748 -305
.eslintrc.js
+7
@@ -455,6 +455,13 @@ module.exports = {
455 __IS_CHROME__: 'readonly',
456 __IS_FIREFOX__: 'readonly',
457 __IS_EDGE__: 'readonly',
458 + __IS_INTERNAL_VERSION__: 'readonly',
459 + },
460 + },
461 + {
462 + files: ['packages/react-devtools-shared/**/*.js'],
463 + globals: {
464 + __IS_INTERNAL_VERSION__: 'readonly',
465 },
466 },
467 ],
packages/react-debug-tools/package.json
+1 -1
@@ -28,6 +28,6 @@
28 "react": "^17.0.0"
29 },
30 "dependencies": {
31 - "error-stack-parser": "^2.0.2"
31 + "error-stack-parser": "^2.1.4"
32 }
33 }
packages/react-devtools-core/src/standalone.js
+35 -16
@@ -33,7 +33,7 @@ import {
33 import {localStorageSetItem} from 'react-devtools-shared/src/storage';
34
35 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
36 -import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
36 +import type {Source} from 'react-devtools-shared/src/shared/types';
37
38 installHook(window);
39
@@ -127,36 +127,55 @@ function reload() {
127 store: ((store: any): Store),
128 warnIfLegacyBackendDetected: true,
129 viewElementSourceFunction,
130 + fetchFileWithCaching,
131 }),
132 );
133 }, 100);
134 }
135
136 +const resourceCache: Map<string, string> = new Map();
137 +
138 +// As a potential improvement, this should be done from the backend of RDT.
139 +// Browser extension is doing this via exchanging messages
140 +// between devtools_page and dedicated content script for it, see `fetchFileWithCaching.js`.
141 +async function fetchFileWithCaching(url: string) {
142 + if (resourceCache.has(url)) {
143 + return Promise.resolve(resourceCache.get(url));
144 + }
145 +
146 + return fetch(url)
147 + .then(data => data.text())
148 + .then(content => {
149 + resourceCache.set(url, content);
150 +
151 + return content;
152 + });
153 +}
154 +
155 function canViewElementSourceFunction(
136 - inspectedElement: InspectedElement,
156 + _source: Source,
157 + symbolicatedSource: Source | null,
158 ): boolean {
138 - if (
139 - inspectedElement.canViewSource === false ||
140 - inspectedElement.source === null
141 - ) {
159 + if (symbolicatedSource == null) {
160 return false;
161 }
162
145 - const {source} = inspectedElement;
146 -
147 - return doesFilePathExist(source.sourceURL, projectRoots);
163 + return doesFilePathExist(symbolicatedSource.sourceURL, projectRoots);
164 }
165
166 function viewElementSourceFunction(
151 - id: number,
152 - inspectedElement: InspectedElement,
167 + _source: Source,
168 + symbolicatedSource: Source | null,
169 ): void {
154 - const {source} = inspectedElement;
155 - if (source !== null) {
156 - launchEditor(source.sourceURL, source.line, projectRoots);
157 - } else {
158 - log.error('Cannot inspect element', id);
170 + if (symbolicatedSource == null) {
171 + return;
172 }
173 +
174 + launchEditor(
175 + symbolicatedSource.sourceURL,
176 + symbolicatedSource.line,
177 + projectRoots,
178 + );
179 }
180
181 function onDisconnected() {
packages/react-devtools-extensions/src/main/fetchFileWithCaching.js
+25 -6
@@ -1,5 +1,6 @@
1 /* global chrome */
2
3 +import {normalizeUrl} from 'react-devtools-shared/src/utils';
4 import {__DEBUG__} from 'react-devtools-shared/src/constants';
5
6 let debugIDCounter = 0;
@@ -107,17 +108,35 @@ const fetchFromPage = async (url, resolve, reject) => {
108 });
109 };
110
110 -// Fetching files from the extension won't make use of the network cache
111 -// for resources that have already been loaded by the page.
112 -// This helper function allows the extension to request files to be fetched
113 -// by the content script (running in the page) to increase the likelihood of a cache hit.
114 -const fetchFileWithCaching = url => {
111 +// 1. Check if resource is available via chrome.devtools.inspectedWindow.getResources
112 +// 2. Check if resource was loaded previously and available in network cache via chrome.devtools.network.getHAR
113 +// 3. Fallback to fetching directly from the page context (from backend)
114 +async function fetchFileWithCaching(url: string): Promise<string> {
115 + if (__IS_CHROME__ || __IS_EDGE__) {
116 + const resources = await new Promise(resolve =>
117 + chrome.devtools.inspectedWindow.getResources(r => resolve(r)),
118 + );
119 +
120 + const normalizedReferenceURL = normalizeUrl(url);
121 + const resource = resources.find(r => r.url === normalizedReferenceURL);
122 +
123 + if (resource != null) {
124 + const content = await new Promise(resolve =>
125 + resource.getContent(fetchedContent => resolve(fetchedContent)),
126 + );
127 +
128 + if (content) {
129 + return content;
130 + }
131 + }
132 + }
133 +
134 return new Promise((resolve, reject) => {
135 // Try fetching from the Network cache first.
136 // If DevTools was opened after the page started loading, we may have missed some requests.
137 // So fall back to a fetch() from the page and hope we get a cached response that way.
138 fetchFromNetworkCache(url, resolve, reject);
139 });
121 -};
140 +}
141
142 export default fetchFileWithCaching;
packages/react-devtools-extensions/src/main/index.js
+8 -32
@@ -128,34 +128,13 @@ function createBridgeAndStore() {
128 }
129 };
130
131 - const viewElementSourceFunction = id => {
132 - const rendererID = store.getRendererIDForElement(id);
133 - if (rendererID != null) {
134 - // Ask the renderer interface to determine the component function,
135 - // and store it as a global variable on the window
136 - bridge.send('viewElementSource', {id, rendererID});
131 + const viewElementSourceFunction = (source, symbolicatedSource) => {
132 + const {sourceURL, line, column} = symbolicatedSource
133 + ? symbolicatedSource
134 + : source;
135
138 - setTimeout(() => {
139 - // Ask Chrome to display the location of the component function,
140 - // or a render method if it is a Class (ideally Class instance, not type)
141 - // assuming the renderer found one.
142 - chrome.devtools.inspectedWindow.eval(`
143 - if (window.$type != null) {
144 - if (
145 - window.$type &&
146 - window.$type.prototype &&
147 - window.$type.prototype.isReactComponent
148 - ) {
149 - // inspect Component.render, not constructor
150 - inspect(window.$type.prototype.render);
151 - } else {
152 - // inspect Functional Component
153 - inspect(window.$type);
154 - }
155 - }
156 - `);
157 - }, 100);
158 - }
136 + // We use 1-based line and column, Chrome expects them 0-based.
137 + chrome.devtools.panels.openResource(sourceURL, line - 1, column - 1);
138 };
139
140 // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration.
@@ -183,17 +162,14 @@ function createBridgeAndStore() {
162 store,
163 warnIfUnsupportedVersionDetected: true,
164 viewAttributeSourceFunction,
165 + // Firefox doesn't support chrome.devtools.panels.openResource yet
166 + canViewElementSourceFunction: () => __IS_CHROME__ || __IS_EDGE__,
167 viewElementSourceFunction,
187 - viewUrlSourceFunction,
168 }),
169 );
170 };
171 }
172
193 -const viewUrlSourceFunction = (url, line, col) => {
194 - chrome.devtools.panels.openResource(url, line, col);
195 -};
196 -
173 function ensureInitialHTMLIsCleared(container) {
174 if (container._hasInitialHTMLBeenCleared) {
175 return;
packages/react-devtools-extensions/webpack.config.js
+2
@@ -39,6 +39,7 @@ const LOGGING_URL = process.env.LOGGING_URL || null;
39 const IS_CHROME = process.env.IS_CHROME === 'true';
40 const IS_FIREFOX = process.env.IS_FIREFOX === 'true';
41 const IS_EDGE = process.env.IS_EDGE === 'true';
42 +const IS_INTERNAL_VERSION = process.env.FEATURE_FLAG_TARGET === 'extension-fb';
43
44 const featureFlagTarget = process.env.FEATURE_FLAG_TARGET || 'extension-oss';
45
@@ -119,6 +120,7 @@ module.exports = {
120 __IS_CHROME__: IS_CHROME,
121 __IS_FIREFOX__: IS_FIREFOX,
122 __IS_EDGE__: IS_EDGE,
123 + __IS_INTERNAL_VERSION__: IS_INTERNAL_VERSION,
124 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-extensions"`,
125 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`,
126 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null,
packages/react-devtools-shared/package.json
+1
@@ -19,6 +19,7 @@
19 "@reach/tooltip": "^0.16.0",
20 "clipboard-js": "^0.3.6",
21 "compare-versions": "^5.0.3",
22 + "jsc-safe-url": "^0.2.4",
23 "json5": "^2.1.3",
24 "local-storage-fallback": "^4.1.1",
25 "lodash.throttle": "^4.1.1",
packages/react-devtools-shared/src/backendAPI.js
+3 -1
@@ -261,7 +261,9 @@ export function convertInspectedElementBackendToFrontend(
261 rendererPackageName,
262 rendererVersion,
263 rootType,
264 - source,
264 + // Previous backend implementations (<= 5.0.1) have a different interface for Source, with fileName.
265 + // This gates the source features for only compatible backends: >= 5.0.2
266 + source: source && source.sourceURL ? source : null,
267 type,
268 owners:
269 owners === null
packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js
+47 -53
@@ -15,7 +15,6 @@ import Button from '../Button';
15 import ButtonIcon from '../ButtonIcon';
16 import Icon from '../Icon';
17 import {ModalDialogContext} from '../ModalDialog';
18 -import ViewElementSourceContext from './ViewElementSourceContext';
18 import Toggle from '../Toggle';
19 import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types';
20 import CannotSuspendWarningMessage from './CannotSuspendWarningMessage';
@@ -23,10 +22,15 @@ import InspectedElementView from './InspectedElementView';
22 import {InspectedElementContext} from './InspectedElementContext';
23 import {getOpenInEditorURL} from '../../../utils';
24 import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants';
25 +import FetchFileWithCachingContext from './FetchFileWithCachingContext';
26 +import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
27 +import OpenInEditorButton from './OpenInEditorButton';
28 +import InspectedElementViewSourceButton from './InspectedElementViewSourceButton';
29 +import Skeleton from './Skeleton';
30
31 import styles from './InspectedElement.css';
32
29 -import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
33 +import type {Source} from 'react-devtools-shared/src/shared/types';
34
35 export type Props = {};
36
@@ -35,9 +39,6 @@ export type Props = {};
39 export default function InspectedElementWrapper(_: Props): React.Node {
40 const {inspectedElementID} = useContext(TreeStateContext);
41 const dispatch = useContext(TreeDispatcherContext);
38 - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
39 - ViewElementSourceContext,
40 - );
42 const bridge = useContext(BridgeContext);
43 const store = useContext(StoreContext);
44 const {
@@ -51,6 +52,25 @@ export default function InspectedElementWrapper(_: Props): React.Node {
52 const {hookNames, inspectedElement, parseHookNames, toggleParseHookNames} =
53 useContext(InspectedElementContext);
54
55 + const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
56 +
57 + const symbolicatedSourcePromise: null | Promise<Source | null> =
58 + React.useMemo(() => {
59 + if (inspectedElement == null) return null;
60 + if (fetchFileWithCaching == null) return Promise.resolve(null);
61 +
62 + const {source} = inspectedElement;
63 + if (source == null) return Promise.resolve(null);
64 +
65 + const {sourceURL, line, column} = source;
66 + return symbolicateSourceWithCache(
67 + fetchFileWithCaching,
68 + sourceURL,
69 + line,
70 + column,
71 + );
72 + }, [inspectedElement]);
73 +
74 const element =
75 inspectedElementID !== null
76 ? store.getElementByID(inspectedElementID)
@@ -84,24 +104,6 @@ export default function InspectedElementWrapper(_: Props): React.Node {
104 }
105 }, [bridge, inspectedElementID, store]);
106
87 - const viewSource = useCallback(() => {
88 - if (viewElementSourceFunction != null && inspectedElement !== null) {
89 - viewElementSourceFunction(
90 - inspectedElement.id,
91 - ((inspectedElement: any): InspectedElement),
92 - );
93 - }
94 - }, [inspectedElement, viewElementSourceFunction]);
95 -
96 - // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
97 - // To detect this case, we defer to an injected helper function (if present).
98 - const canViewSource =
99 - inspectedElement !== null &&
100 - inspectedElement.canViewSource &&
101 - viewElementSourceFunction !== null &&
102 - (canViewElementSourceFunction === null ||
103 - canViewElementSourceFunction(inspectedElement));
104 -
107 const isErrored = inspectedElement != null && inspectedElement.isErrored;
108 const targetErrorBoundaryID =
109 inspectedElement != null ? inspectedElement.targetErrorBoundaryID : null;
@@ -134,9 +136,6 @@ export default function InspectedElementWrapper(_: Props): React.Node {
136 },
137 );
138
137 - const canOpenInEditor =
138 - editorURL && inspectedElement != null && inspectedElement.source != null;
139 -
139 const toggleErrored = useCallback(() => {
140 if (inspectedElement == null || targetErrorBoundaryID == null) {
141 return;
@@ -212,21 +211,6 @@ export default function InspectedElementWrapper(_: Props): React.Node {
211 }
212 }, [bridge, dispatch, element, isSuspended, modalDialogDispatch, store]);
213
215 - const onOpenInEditor = useCallback(() => {
216 - const source = inspectedElement?.source;
217 - if (source == null || editorURL == null) {
218 - return;
219 - }
220 -
221 - const url = new URL(editorURL);
222 - url.href = url.href
223 - .replace('{path}', source.sourceURL)
224 - .replace('{line}', String(source.line))
225 - .replace('%7Bpath%7D', source.sourceURL)
226 - .replace('%7Bline%7D', String(source.line));
227 - window.open(url);
228 - }, [inspectedElement, editorURL]);
229 -
214 if (element === null) {
215 return (
216 <div className={styles.InspectedElement}>
@@ -274,11 +258,20 @@ export default function InspectedElementWrapper(_: Props): React.Node {
258 {element.displayName}
259 </div>
260 </div>
277 - {canOpenInEditor && (
278 - <Button onClick={onOpenInEditor} title="Open in editor">
279 - <ButtonIcon type="editor" />
280 - </Button>
281 - )}
261 +
262 + {!!editorURL &&
263 + inspectedElement != null &&
264 + inspectedElement.source != null &&
265 + symbolicatedSourcePromise != null && (
266 + <React.Suspense fallback={<Skeleton height={16} width={24} />}>
267 + <OpenInEditorButton
268 + editorURL={editorURL}
269 + source={inspectedElement.source}
270 + symbolicatedSourcePromise={symbolicatedSourcePromise}
271 + />
272 + </React.Suspense>
273 + )}
274 +
275 {canToggleError && (
276 <Toggle
277 isChecked={isErrored}
@@ -317,13 +310,13 @@ export default function InspectedElementWrapper(_: Props): React.Node {
310 <ButtonIcon type="log-data" />
311 </Button>
312 )}
313 +
314 {!hideViewSourceAction && (
321 - <Button
322 - disabled={!canViewSource}
323 - onClick={viewSource}
324 - title="View source for this element">
325 - <ButtonIcon type="view-source" />
326 - </Button>
315 + <InspectedElementViewSourceButton
316 + canViewSource={inspectedElement?.canViewSource}
317 + source={inspectedElement?.source}
318 + symbolicatedSourcePromise={symbolicatedSourcePromise}
319 + />
320 )}
321 </div>
322
@@ -331,7 +324,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
324 <div className={styles.Loading}>Loading...</div>
325 )}
326
334 - {inspectedElement !== null && (
327 + {inspectedElement !== null && symbolicatedSourcePromise != null && (
328 <InspectedElementView
329 key={
330 inspectedElementID /* Force reset when selected Element changes */
@@ -341,6 +334,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
334 inspectedElement={inspectedElement}
335 parseHookNames={parseHookNames}
336 toggleParseHookNames={toggleParseHookNames}
337 + symbolicatedSourcePromise={symbolicatedSourcePromise}
338 />
339 )}
340 </div>
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js
-11
@@ -174,17 +174,6 @@ export function InspectedElementContextController({
174 [setState, state],
175 );
176
177 - const inspectedElementRef = useRef<null | InspectedElement>(null);
178 - useEffect(() => {
179 - if (
180 - inspectedElement !== null &&
181 - inspectedElement.hooks !== null &&
182 - inspectedElementRef.current !== inspectedElement
183 - ) {
184 - inspectedElementRef.current = inspectedElement;
185 - }
186 - }, [inspectedElement]);
187 -
177 useEffect(() => {
178 const purgeCachedMetadata = purgeCachedMetadataRef.current;
179 if (typeof purgeCachedMetadata === 'function') {
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.css new
+25
@@ -0,0 +1,25 @@
1 +.Source {
2 + padding: 0.25rem;
3 + border-top: 1px solid var(--color-border);
4 +}
5 +
6 +.SourceHeaderRow {
7 + display: flex;
8 + align-items: center;
9 + min-height: 24px;
10 +}
11 +
12 +.SourceHeader {
13 + flex: 1 1;
14 + font-family: var(--font-family-sans);
15 +}
16 +
17 +.SourceOneLiner {
18 + font-family: var(--font-family-monospace);
19 + font-size: var(--font-size-monospace-normal);
20 + white-space: nowrap;
21 + overflow: hidden;
22 + text-overflow: ellipsis;
23 + max-width: 100%;
24 + margin-left: 1rem;
25 +}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js new
+132
@@ -0,0 +1,132 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import * as React from 'react';
11 +import {copy} from 'clipboard-js';
12 +import {toNormalUrl} from 'jsc-safe-url';
13 +
14 +import Button from '../Button';
15 +import ButtonIcon from '../ButtonIcon';
16 +import Skeleton from './Skeleton';
17 +
18 +import type {Source as InspectedElementSource} from 'react-devtools-shared/src/shared/types';
19 +import styles from './InspectedElementSourcePanel.css';
20 +
21 +type Props = {
22 + source: InspectedElementSource,
23 + symbolicatedSourcePromise: Promise<InspectedElementSource | null>,
24 +};
25 +
26 +function InspectedElementSourcePanel({
27 + source,
28 + symbolicatedSourcePromise,
29 +}: Props): React.Node {
30 + return (
31 + <div className={styles.Source} data-testname="InspectedElementView-Source">
32 + <div className={styles.SourceHeaderRow}>
33 + <div className={styles.SourceHeader}>source</div>
34 +
35 + <React.Suspense fallback={<Skeleton height={16} width={16} />}>
36 + <CopySourceButton
37 + source={source}
38 + symbolicatedSourcePromise={symbolicatedSourcePromise}
39 + />
40 + </React.Suspense>
41 + </div>
42 +
43 + <React.Suspense
44 + fallback={
45 + <div className={styles.SourceOneLiner}>
46 + <Skeleton height={16} width="40%" />
47 + </div>
48 + }>
49 + <FormattedSourceString
50 + source={source}
51 + symbolicatedSourcePromise={symbolicatedSourcePromise}
52 + />
53 + </React.Suspense>
54 + </div>
55 + );
56 +}
57 +
58 +function CopySourceButton({source, symbolicatedSourcePromise}: Props) {
59 + const symbolicatedSource = React.use(symbolicatedSourcePromise);
60 + if (symbolicatedSource == null) {
61 + const {sourceURL, line, column} = source;
62 + const handleCopy = () => copy(`${sourceURL}:${line}:${column}`);
63 +
64 + return (
65 + <Button onClick={handleCopy} title="Copy to clipboard">
66 + <ButtonIcon type="copy" />
67 + </Button>
68 + );
69 + }
70 +
71 + const {sourceURL, line, column} = symbolicatedSource;
72 + const handleCopy = () => copy(`${sourceURL}:${line}:${column}`);
73 +
74 + return (
75 + <Button onClick={handleCopy} title="Copy to clipboard">
76 + <ButtonIcon type="copy" />
77 + </Button>
78 + );
79 +}
80 +
81 +function FormattedSourceString({source, symbolicatedSourcePromise}: Props) {
82 + const symbolicatedSource = React.use(symbolicatedSourcePromise);
83 + if (symbolicatedSource == null) {
84 + const {sourceURL, line} = source;
85 +
86 + return (
87 + <div className={styles.SourceOneLiner}>
88 + {formatSourceForDisplay(sourceURL, line)}
89 + </div>
90 + );
91 + }
92 +
93 + const {sourceURL, line} = symbolicatedSource;
94 +
95 + return (
96 + <div className={styles.SourceOneLiner}>
97 + {formatSourceForDisplay(sourceURL, line)}
98 + </div>
99 + );
100 +}
101 +
102 +// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame
103 +function formatSourceForDisplay(sourceURL: string, line: number) {
104 + // Metro can return JSC-safe URLs, which have `//&` as a delimiter
105 + // https://www.npmjs.com/package/jsc-safe-url
106 + const sanitizedSourceURL = sourceURL.includes('//&')
107 + ? toNormalUrl(sourceURL)
108 + : sourceURL;
109 +
110 + // Note: this RegExp doesn't work well with URLs from Metro,
111 + // which provides bundle URL with query parameters prefixed with /&
112 + const BEFORE_SLASH_RE = /^(.*)[\\\/]/;
113 +
114 + let nameOnly = sanitizedSourceURL.replace(BEFORE_SLASH_RE, '');
115 +
116 + // In DEV, include code for a common special case:
117 + // prefer "folder/index.js" instead of just "index.js".
118 + if (/^index\./.test(nameOnly)) {
119 + const match = sanitizedSourceURL.match(BEFORE_SLASH_RE);
120 + if (match) {
121 + const pathBeforeSlash = match[1];
122 + if (pathBeforeSlash) {
123 + const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, '');
124 + nameOnly = folderName + '/' + nameOnly;
125 + }
126 + }
127 + }
128 +
129 + return `${nameOnly}:${line}`;
130 +}
131 +
132 +export default InspectedElementSourcePanel;
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css
+1 -26
@@ -7,31 +7,6 @@
7 font-family: var(--font-family-sans);
8 }
9
10 -.Source {
11 - padding: 0.25rem;
12 - border-top: 1px solid var(--color-border);
13 -}
14 -
15 -.SourceHeaderRow {
16 - display: flex;
17 - align-items: center;
18 -}
19 -
20 -.SourceHeader {
21 - flex: 1 1;
22 - font-family: var(--font-family-sans);
23 -}
24 -
25 -.SourceOneLiner {
26 - font-family: var(--font-family-monospace);
27 - font-size: var(--font-size-monospace-normal);
28 - white-space: nowrap;
29 - overflow: hidden;
30 - text-overflow: ellipsis;
31 - max-width: 100%;
32 - margin-left: 1rem;
33 -}
34 -
10 .Owner {
11 color: var(--color-component-name);
12 font-family: var(--font-family-monospace);
@@ -94,4 +69,4 @@
69 white-space: nowrap;
70 overflow: hidden;
71 text-overflow: ellipsis;
97 -}
\ No newline at end of file
72 +}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js
+9 -53
@@ -7,7 +7,6 @@
7 * @flow
8 */
9
10 -import {copy} from 'clipboard-js';
10 import * as React from 'react';
11 import {Fragment, useCallback, useContext} from 'react';
12 import {TreeDispatcherContext} from './TreeContext';
@@ -15,7 +14,6 @@ import {BridgeContext, ContextMenuContext, StoreContext} from '../context';
14 import ContextMenu from '../../ContextMenu/ContextMenu';
15 import ContextMenuItem from '../../ContextMenu/ContextMenuItem';
16 import Button from '../Button';
18 -import ButtonIcon from '../ButtonIcon';
17 import Icon from '../Icon';
18 import InspectedElementBadges from './InspectedElementBadges';
19 import InspectedElementContextTree from './InspectedElementContextTree';
@@ -34,6 +32,7 @@ import {
32 } from 'react-devtools-shared/src/backendAPI';
33 import {enableStyleXFeatures} from 'react-devtools-feature-flags';
34 import {logEvent} from 'react-devtools-shared/src/Logger';
35 +import InspectedElementSourcePanel from './InspectedElementSourcePanel';
36
37 import styles from './InspectedElementView.css';
38
@@ -44,9 +43,7 @@ import type {
43 } from 'react-devtools-shared/src/frontend/types';
44 import type {HookNames} from 'react-devtools-shared/src/frontend/types';
45 import type {ToggleParseHookNames} from './InspectedElementContext';
47 -
48 -export type CopyPath = (path: Array<string | number>) => void;
49 -export type InspectPath = (path: Array<string | number>) => void;
46 +import type {Source} from 'react-devtools-shared/src/shared/types';
47
48 type Props = {
49 element: Element,
@@ -54,6 +51,7 @@ type Props = {
51 inspectedElement: InspectedElement,
52 parseHookNames: boolean,
53 toggleParseHookNames: ToggleParseHookNames,
54 + symbolicatedSourcePromise: Promise<Source | null>,
55 };
56
57 export default function InspectedElementView({
@@ -62,6 +60,7 @@ export default function InspectedElementView({
60 inspectedElement,
61 parseHookNames,
62 toggleParseHookNames,
63 + symbolicatedSourcePromise,
64 }: Props): React.Node {
65 const {id} = element;
66 const {owners, rendererPackageName, rendererVersion, rootType, source} =
@@ -171,8 +170,11 @@ export default function InspectedElementView({
170 </div>
171 )}
172
174 - {source !== null && (
175 - <Source sourceURL={source.sourceURL} line={source.line} />
173 + {source != null && (
174 + <InspectedElementSourcePanel
175 + source={source}
176 + symbolicatedSourcePromise={symbolicatedSourcePromise}
177 + />
178 )}
179 </div>
180
@@ -238,52 +240,6 @@ export default function InspectedElementView({
240 );
241 }
242
241 -// This function is based on describeComponentFrame() in packages/shared/ReactComponentStackFrame
242 -function formatSourceForDisplay(sourceURL: string, line: number) {
243 - // Note: this RegExp doesn't work well with URLs from Metro,
244 - // which provides bundle URL with query parameters prefixed with /&
245 - const BEFORE_SLASH_RE = /^(.*)[\\\/]/;
246 -
247 - let nameOnly = sourceURL.replace(BEFORE_SLASH_RE, '');
248 -
249 - // In DEV, include code for a common special case:
250 - // prefer "folder/index.js" instead of just "index.js".
251 - if (/^index\./.test(nameOnly)) {
252 - const match = sourceURL.match(BEFORE_SLASH_RE);
253 - if (match) {
254 - const pathBeforeSlash = match[1];
255 - if (pathBeforeSlash) {
256 - const folderName = pathBeforeSlash.replace(BEFORE_SLASH_RE, '');
257 - nameOnly = folderName + '/' + nameOnly;
258 - }
259 - }
260 - }
261 -
262 - return `${nameOnly}:${sourceURL}`;
263 -}
264 -
265 -type SourceProps = {
266 - sourceURL: string,
267 - line: number,
268 -};
269 -
270 -function Source({sourceURL, line}: SourceProps) {
271 - const handleCopy = () => copy(`${sourceURL}:${line}`);
272 - return (
273 - <div className={styles.Source} data-testname="InspectedElementView-Source">
274 - <div className={styles.SourceHeaderRow}>
275 - <div className={styles.SourceHeader}>source</div>
276 - <Button onClick={handleCopy} title="Copy to clipboard">
277 - <ButtonIcon type="copy" />
278 - </Button>
279 - </div>
280 - <div className={styles.SourceOneLiner}>
281 - {formatSourceForDisplay(sourceURL, line)}
282 - </div>
283 - </div>
284 - );
285 -}
286 -
243 type OwnerViewProps = {
244 displayName: string,
245 hocDisplayNames: Array<string> | null,
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js new
+97
@@ -0,0 +1,97 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import * as React from 'react';
11 +
12 +import ButtonIcon from '../ButtonIcon';
13 +import Button from '../Button';
14 +import ViewElementSourceContext from './ViewElementSourceContext';
15 +import Skeleton from './Skeleton';
16 +
17 +import type {Source as InspectedElementSource} from 'react-devtools-shared/src/shared/types';
18 +import type {
19 + CanViewElementSource,
20 + ViewElementSource,
21 +} from 'react-devtools-shared/src/devtools/views/DevTools';
22 +
23 +const {useCallback, useContext} = React;
24 +
25 +type Props = {
26 + canViewSource: ?boolean,
27 + source: ?InspectedElementSource,
28 + symbolicatedSourcePromise: Promise<InspectedElementSource | null> | null,
29 +};
30 +
31 +function InspectedElementViewSourceButton({
32 + canViewSource,
33 + source,
34 + symbolicatedSourcePromise,
35 +}: Props): React.Node {
36 + const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
37 + ViewElementSourceContext,
38 + );
39 +
40 + return (
41 + <React.Suspense fallback={<Skeleton height={16} width={24} />}>
42 + <ActualSourceButton
43 + canViewSource={canViewSource}
44 + source={source}
45 + symbolicatedSourcePromise={symbolicatedSourcePromise}
46 + canViewElementSourceFunction={canViewElementSourceFunction}
47 + viewElementSourceFunction={viewElementSourceFunction}
48 + />
49 + </React.Suspense>
50 + );
51 +}
52 +
53 +type ActualSourceButtonProps = {
54 + canViewSource: ?boolean,
55 + source: ?InspectedElementSource,
56 + symbolicatedSourcePromise: Promise<InspectedElementSource | null> | null,
57 + canViewElementSourceFunction: CanViewElementSource | null,
58 + viewElementSourceFunction: ViewElementSource | null,
59 +};
60 +function ActualSourceButton({
61 + canViewSource,
62 + source,
63 + symbolicatedSourcePromise,
64 + canViewElementSourceFunction,
65 + viewElementSourceFunction,
66 +}: ActualSourceButtonProps): React.Node {
67 + const symbolicatedSource =
68 + symbolicatedSourcePromise == null
69 + ? null
70 + : React.use(symbolicatedSourcePromise);
71 +
72 + // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
73 + // To detect this case, we defer to an injected helper function (if present).
74 + const buttonIsEnabled =
75 + !!canViewSource &&
76 + viewElementSourceFunction != null &&
77 + source != null &&
78 + (canViewElementSourceFunction == null ||
79 + canViewElementSourceFunction(source, symbolicatedSource));
80 +
81 + const viewSource = useCallback(() => {
82 + if (viewElementSourceFunction != null && source != null) {
83 + viewElementSourceFunction(source, symbolicatedSource);
84 + }
85 + }, [source, symbolicatedSource]);
86 +
87 + return (
88 + <Button
89 + disabled={!buttonIsEnabled}
90 + onClick={viewSource}
91 + title="View source for this element">
92 + <ButtonIcon type="view-source" />
93 + </Button>
94 + );
95 +}
96 +
97 +export default InspectedElementViewSourceButton;
packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js new
+90
@@ -0,0 +1,90 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + */
8 +
9 +import * as React from 'react';
10 +
11 +import Button from 'react-devtools-shared/src/devtools/views/Button';
12 +import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
13 +
14 +import type {Source} from 'react-devtools-shared/src/shared/types';
15 +
16 +type Props = {
17 + editorURL: string,
18 + source: Source,
19 + symbolicatedSourcePromise: Promise<Source | null>,
20 +};
21 +
22 +function checkConditions(
23 + editorURL: string,
24 + source: Source,
25 +): {url: URL | null, shouldDisableButton: boolean} {
26 + try {
27 + const url = new URL(editorURL);
28 +
29 + let sourceURL = source.sourceURL;
30 +
31 + // Check if sourceURL is a correct URL, which has a protocol specified
32 + if (sourceURL.includes('://')) {
33 + if (!__IS_INTERNAL_VERSION__) {
34 + // In this case, we can't really determine the path to a file, disable a button
35 + return {url: null, shouldDisableButton: true};
36 + } else {
37 + const endOfSourceMapURLPattern = '.js/';
38 + const endOfSourceMapURLIndex = sourceURL.lastIndexOf(
39 + endOfSourceMapURLPattern,
40 + );
41 +
42 + if (endOfSourceMapURLIndex === -1) {
43 + return {url: null, shouldDisableButton: true};
44 + } else {
45 + sourceURL = sourceURL.slice(
46 + endOfSourceMapURLIndex + endOfSourceMapURLPattern.length,
47 + sourceURL.length,
48 + );
49 + }
50 + }
51 + }
52 +
53 + const lineNumberAsString = String(source.line);
54 +
55 + url.href = url.href
56 + .replace('{path}', sourceURL)
57 + .replace('{line}', lineNumberAsString)
58 + .replace('%7Bpath%7D', sourceURL)
59 + .replace('%7Bline%7D', lineNumberAsString);
60 +
61 + return {url, shouldDisableButton: false};
62 + } catch (e) {
63 + // User has provided incorrect editor url
64 + return {url: null, shouldDisableButton: true};
65 + }
66 +}
67 +
68 +function OpenInEditorButton({
69 + editorURL,
70 + source,
71 + symbolicatedSourcePromise,
72 +}: Props): React.Node {
73 + const symbolicatedSource = React.use(symbolicatedSourcePromise);
74 +
75 + const {url, shouldDisableButton} = checkConditions(
76 + editorURL,
77 + symbolicatedSource ? symbolicatedSource : source,
78 + );
79 +
80 + return (
81 + <Button
82 + disabled={shouldDisableButton}
83 + onClick={() => window.open(url)}
84 + title="Open in editor">
85 + <ButtonIcon type="editor" />
86 + </Button>
87 + );
88 +}
89 +
90 +export default OpenInEditorButton;
packages/react-devtools-shared/src/devtools/views/Components/Skeleton.css new
+13
@@ -0,0 +1,13 @@
1 +.root {
2 + border-radius: 0.25rem;
3 + animation: pulse 2s infinite;
4 +}
5 +
6 +@keyframes pulse {
7 + 0%, 100% {
8 + background-color: var(--color-dim);
9 + }
10 + 50% {
11 + background-color: var(--color-dimmest)
12 + }
13 +}
packages/react-devtools-shared/src/devtools/views/Components/Skeleton.js new
+23
@@ -0,0 +1,23 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import * as React from 'react';
11 +
12 +import styles from './Skeleton.css';
13 +
14 +type Props = {
15 + height: number | string,
16 + width: number | string,
17 +};
18 +
19 +function Skeleton({height, width}: Props): React.Node {
20 + return <div className={styles.root} style={{height, width}} />;
21 +}
22 +
23 +export default Skeleton;
packages/react-devtools-shared/src/devtools/views/Components/ViewSourceContext.js deleted
-25
@@ -1,25 +0,0 @@
1 -/**
2 - * Copyright (c) Meta Platforms, Inc. and affiliates.
3 - *
4 - * This source code is licensed under the MIT license found in the
5 - * LICENSE file in the root directory of this source tree.
6 - *
7 - * @flow
8 - */
9 -
10 -import type {ReactContext} from 'shared/ReactTypes';
11 -
12 -import {createContext} from 'react';
13 -
14 -import type {ViewUrlSource} from 'react-devtools-shared/src/devtools/views/DevTools';
15 -
16 -export type Context = {
17 - viewUrlSourceFunction: ViewUrlSource | null,
18 -};
19 -
20 -const ViewSourceContext: ReactContext<Context> = createContext<Context>(
21 - ((null: any): Context),
22 -);
23 -ViewSourceContext.displayName = 'ViewSourceContext';
24 -
25 -export default ViewSourceContext;
packages/react-devtools-shared/src/devtools/views/DevTools.js
+51 -67
@@ -27,7 +27,6 @@ import TabBar from './TabBar';
27 import {SettingsContextController} from './Settings/SettingsContext';
28 import {TreeContextController} from './Components/TreeContext';
29 import ViewElementSourceContext from './Components/ViewElementSourceContext';
30 -import ViewSourceContext from './Components/ViewSourceContext';
30 import FetchFileWithCachingContext from './Components/FetchFileWithCachingContext';
31 import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
32 import {ProfilerContextController} from './Profiler/ProfilerContext';
@@ -46,25 +45,25 @@ import styles from './DevTools.css';
45
46 import './root.css';
47
49 -import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
48 import type {FetchFileWithCaching} from './Components/FetchFileWithCachingContext';
49 import type {HookNamesModuleLoaderFunction} from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
50 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
51 import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
52 +import type {Source} from 'react-devtools-shared/src/shared/types';
53
54 export type TabID = 'components' | 'profiler';
55
56 export type ViewElementSource = (
58 - id: number,
59 - inspectedElement: InspectedElement,
57 + source: Source,
58 + symbolicatedSource: Source | null,
59 ) => void;
61 -export type ViewUrlSource = (url: string, row: number, column: number) => void;
60 export type ViewAttributeSource = (
61 id: number,
62 path: Array<string | number>,
63 ) => void;
64 export type CanViewElementSource = (
67 - inspectedElement: InspectedElement,
65 + source: Source,
66 + symbolicatedSource: Source | null,
67 ) => boolean;
68
69 export type Props = {
@@ -79,7 +78,6 @@ export type Props = {
78 warnIfUnsupportedVersionDetected?: boolean,
79 viewAttributeSourceFunction?: ?ViewAttributeSource,
80 viewElementSourceFunction?: ?ViewElementSource,
82 - viewUrlSourceFunction?: ?ViewUrlSource,
81 readOnly?: boolean,
82 hideSettings?: boolean,
83 hideToggleErrorAction?: boolean,
@@ -139,7 +137,6 @@ export default function DevTools({
137 warnIfUnsupportedVersionDetected = false,
138 viewAttributeSourceFunction,
139 viewElementSourceFunction,
142 - viewUrlSourceFunction,
140 readOnly,
141 hideSettings,
142 hideToggleErrorAction,
@@ -203,15 +200,6 @@ export default function DevTools({
200 [canViewElementSourceFunction, viewElementSourceFunction],
201 );
202
206 - const viewSource = useMemo(
207 - () => ({
208 - viewUrlSourceFunction: viewUrlSourceFunction || null,
209 - // todo(blakef): Add inspect(...) method here and remove viewElementSource
210 - // to consolidate source code inspection.
211 - }),
212 - [viewUrlSourceFunction],
213 - );
214 -
203 const contextMenu = useMemo(
204 () => ({
205 isEnabledForInspectedElement: enabledInspectedElementContextMenu,
@@ -281,59 +269,55 @@ export default function DevTools({
269 componentsPortalContainer={componentsPortalContainer}
270 profilerPortalContainer={profilerPortalContainer}>
271 <ViewElementSourceContext.Provider value={viewElementSource}>
284 - <ViewSourceContext.Provider value={viewSource}>
285 - <HookNamesModuleLoaderContext.Provider
286 - value={hookNamesModuleLoaderFunction || null}>
287 - <FetchFileWithCachingContext.Provider
288 - value={fetchFileWithCaching || null}>
289 - <TreeContextController>
290 - <ProfilerContextController>
291 - <TimelineContextController>
292 - <ThemeProvider>
293 - <div
294 - className={styles.DevTools}
295 - ref={devToolsRef}
296 - data-react-devtools-portal-root={true}>
297 - {showTabBar && (
298 - <div className={styles.TabBar}>
299 - <ReactLogo />
300 - <span className={styles.DevToolsVersion}>
301 - {process.env.DEVTOOLS_VERSION}
302 - </span>
303 - <div className={styles.Spacer} />
304 - <TabBar
305 - currentTab={tab}
306 - id="DevTools"
307 - selectTab={selectTab}
308 - tabs={tabs}
309 - type="navigation"
310 - />
311 - </div>
312 - )}
313 - <div
314 - className={styles.TabContent}
315 - hidden={tab !== 'components'}>
316 - <Components
317 - portalContainer={
318 - componentsPortalContainer
319 - }
320 - />
321 - </div>
322 - <div
323 - className={styles.TabContent}
324 - hidden={tab !== 'profiler'}>
325 - <Profiler
326 - portalContainer={profilerPortalContainer}
272 + <HookNamesModuleLoaderContext.Provider
273 + value={hookNamesModuleLoaderFunction || null}>
274 + <FetchFileWithCachingContext.Provider
275 + value={fetchFileWithCaching || null}>
276 + <TreeContextController>
277 + <ProfilerContextController>
278 + <TimelineContextController>
279 + <ThemeProvider>
280 + <div
281 + className={styles.DevTools}
282 + ref={devToolsRef}
283 + data-react-devtools-portal-root={true}>
284 + {showTabBar && (
285 + <div className={styles.TabBar}>
286 + <ReactLogo />
287 + <span className={styles.DevToolsVersion}>
288 + {process.env.DEVTOOLS_VERSION}
289 + </span>
290 + <div className={styles.Spacer} />
291 + <TabBar
292 + currentTab={tab}
293 + id="DevTools"
294 + selectTab={selectTab}
295 + tabs={tabs}
296 + type="navigation"
297 />
298 </div>
299 + )}
300 + <div
301 + className={styles.TabContent}
302 + hidden={tab !== 'components'}>
303 + <Components
304 + portalContainer={componentsPortalContainer}
305 + />
306 + </div>
307 + <div
308 + className={styles.TabContent}
309 + hidden={tab !== 'profiler'}>
310 + <Profiler
311 + portalContainer={profilerPortalContainer}
312 + />
313 </div>
330 - </ThemeProvider>
331 - </TimelineContextController>
332 - </ProfilerContextController>
333 - </TreeContextController>
334 - </FetchFileWithCachingContext.Provider>
335 - </HookNamesModuleLoaderContext.Provider>
336 - </ViewSourceContext.Provider>
314 + </div>
315 + </ThemeProvider>
316 + </TimelineContextController>
317 + </ProfilerContextController>
318 + </TreeContextController>
319 + </FetchFileWithCachingContext.Provider>
320 + </HookNamesModuleLoaderContext.Provider>
321 </ViewElementSourceContext.Provider>
322 </SettingsContextController>
323 <UnsupportedBridgeProtocolDialog />
packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js
+33 -13
@@ -7,13 +7,12 @@
7 * @flow
8 */
9
10 -import type {Stack} from '../../utils';
10 import type {SchedulingEvent} from 'react-devtools-timeline/src/types';
11
12 import * as React from 'react';
13 import Button from '../Button';
14 import ButtonIcon from '../ButtonIcon';
16 -import ViewSourceContext from '../Components/ViewSourceContext';
15 +import ViewElementSourceContext from '../Components/ViewElementSourceContext';
16 import {useContext} from 'react';
17 import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
18 import {
@@ -32,16 +31,12 @@ type SchedulingEventProps = {
31 };
32
33 function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
35 - const {viewUrlSourceFunction} = useContext(ViewSourceContext);
34 + const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
35 + ViewElementSourceContext,
36 + );
37 const {componentName, timestamp} = eventInfo;
38 const componentStack = eventInfo.componentStack || null;
39
39 - const viewSource = (source: ?Stack) => {
40 - if (viewUrlSourceFunction != null && source != null) {
41 - viewUrlSourceFunction(...source);
42 - }
43 - };
44 -
40 return (
41 <>
42 <div className={styles.Toolbar}>
@@ -65,17 +60,42 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
60 </div>
61 <ul className={styles.List}>
62 {stackToComponentSources(componentStack).map(
68 - ([displayName, source], index) => {
63 + ([displayName, stack], index) => {
64 + if (stack == null) {
65 + return (
66 + <li key={index}>
67 + <Button
68 + className={styles.UnclickableSource}
69 + disabled={true}>
70 + {displayName}
71 + </Button>
72 + </li>
73 + );
74 + }
75 +
76 + // TODO: We should support symbolication here as well, but
77 + // symbolicating the whole stack can be expensive
78 + const [sourceURL, line, column] = stack;
79 + const source = {sourceURL, line, column};
80 + const canViewSource =
81 + canViewElementSourceFunction == null ||
82 + canViewElementSourceFunction(source, null);
83 +
84 + const viewSource =
85 + !canViewSource || viewElementSourceFunction == null
86 + ? () => null
87 + : () => viewElementSourceFunction(source, null);
88 +
89 return (
90 <li key={index}>
91 <Button
92 className={
73 - source
93 + canViewSource
94 ? styles.ClickableSource
95 : styles.UnclickableSource
96 }
77 - disabled={!source}
78 - onClick={() => viewSource(source)}>
97 + disabled={!canViewSource}
98 + onClick={viewSource}>
99 {displayName}
100 </Button>
101 </li>
packages/react-devtools-shared/src/symbolicateSource.js new
+122
@@ -0,0 +1,122 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {normalizeUrl} from 'react-devtools-shared/src/utils';
11 +import SourceMapConsumer from 'react-devtools-shared/src/hooks/SourceMapConsumer';
12 +
13 +import type {Source} from 'react-devtools-shared/src/shared/types';
14 +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext';
15 +
16 +const symbolicationCache: Map<string, Promise<Source | null>> = new Map();
17 +
18 +export async function symbolicateSourceWithCache(
19 + fetchFileWithCaching: FetchFileWithCaching,
20 + sourceURL: string,
21 + line: number, // 1-based
22 + column: number, // 1-based
23 +): Promise<Source | null> {
24 + const key = `${sourceURL}:${line}:${column}`;
25 + const cachedPromise = symbolicationCache.get(key);
26 + if (cachedPromise != null) {
27 + return cachedPromise;
28 + }
29 +
30 + const promise = symbolicateSource(
31 + fetchFileWithCaching,
32 + sourceURL,
33 + line,
34 + column,
35 + );
36 + symbolicationCache.set(key, promise);
37 +
38 + return promise;
39 +}
40 +
41 +const SOURCE_MAP_ANNOTATION_PREFIX = 'sourceMappingURL=';
42 +async function symbolicateSource(
43 + fetchFileWithCaching: FetchFileWithCaching,
44 + sourceURL: string,
45 + lineNumber: number, // 1-based
46 + columnNumber: number, // 1-based
47 +): Promise<Source | null> {
48 + const resource = await fetchFileWithCaching(sourceURL).catch(() => null);
49 + if (resource == null) {
50 + return null;
51 + }
52 +
53 + const resourceLines = resource.split(/[\r\n]+/);
54 + for (let i = resourceLines.length - 1; i >= 0; --i) {
55 + const resourceLine = resourceLines[i];
56 +
57 + // In case there is empty last line
58 + if (!resourceLine) continue;
59 + // Not an annotation? Stop looking for a source mapping url.
60 + if (!resourceLine.startsWith('//#')) break;
61 +
62 + if (resourceLine.includes(SOURCE_MAP_ANNOTATION_PREFIX)) {
63 + const sourceMapAnnotationStartIndex = resourceLine.indexOf(
64 + SOURCE_MAP_ANNOTATION_PREFIX,
65 + );
66 + const sourceMapURL = resourceLine.slice(
67 + sourceMapAnnotationStartIndex + SOURCE_MAP_ANNOTATION_PREFIX.length,
68 + resourceLine.length,
69 + );
70 +
71 + const sourceMap = await fetchFileWithCaching(sourceMapURL).catch(
72 + () => null,
73 + );
74 + if (sourceMap != null) {
75 + try {
76 + const parsedSourceMap = JSON.parse(sourceMap);
77 + const consumer = SourceMapConsumer(parsedSourceMap);
78 + const {
79 + sourceURL: possiblyURL,
80 + line,
81 + column,
82 + } = consumer.originalPositionFor({
83 + lineNumber, // 1-based
84 + columnNumber, // 1-based
85 + });
86 +
87 + try {
88 + void new URL(possiblyURL); // This is a valid URL
89 + const normalizedURL = normalizeUrl(possiblyURL);
90 +
91 + return {sourceURL: normalizedURL, line, column};
92 + } catch (e) {
93 + // This is not valid URL
94 + if (possiblyURL.startsWith('/')) {
95 + // This is an absolute path
96 + return {sourceURL: possiblyURL, line, column};
97 + }
98 +
99 + // This is a relative path
100 + const [sourceMapAbsolutePathWithoutQueryParameters] =
101 + sourceMapURL.split(/[?#&]/);
102 +
103 + const absoluteSourcePath =
104 + sourceMapAbsolutePathWithoutQueryParameters +
105 + (sourceMapAbsolutePathWithoutQueryParameters.endsWith('/')
106 + ? ''
107 + : '/') +
108 + possiblyURL;
109 +
110 + return {sourceURL: absoluteSourcePath, line, column};
111 + }
112 + } catch (e) {
113 + return null;
114 + }
115 + }
116 +
117 + return null;
118 + }
119 + }
120 +
121 + return null;
122 +}
packages/react-devtools-shared/src/utils.js
+5
@@ -955,3 +955,8 @@ export function backendToFrontendSerializedElementMapper(
955 compiledWithForget,
956 };
957 }
958 +
959 +// This is a hacky one to just support this exact case.
960 +export function normalizeUrl(url: string): string {
961 + return url.replace('/./', '/');
962 +}
yarn.lock
+18 -1
@@ -6789,13 +6789,20 @@ error-ex@^1.2.0, error-ex@^1.3.1:
6789 dependencies:
6790 is-arrayish "^0.2.1"
6791
6792 -error-stack-parser@^2.0.2, error-stack-parser@^2.0.6:
6792 +error-stack-parser@^2.0.6:
6793 version "2.0.6"
6794 resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8"
6795 integrity sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==
6796 dependencies:
6797 stackframe "^1.1.1"
6798
6799 +error-stack-parser@^2.1.4:
6800 + version "2.1.4"
6801 + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.1.4.tgz#229cb01cdbfa84440bfa91876285b94680188286"
6802 + integrity sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==
6803 + dependencies:
6804 + stackframe "^1.3.4"
6805 +
6806 es-abstract@^1.13.0:
6807 version "1.13.0"
6808 resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9"
@@ -10311,6 +10318,11 @@ jsbn@~0.1.0:
10318 resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
10319 integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
10320
10321 +jsc-safe-url@^0.2.4:
10322 + version "0.2.4"
10323 + resolved "https://registry.yarnpkg.com/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz#141c14fbb43791e88d5dc64e85a374575a83477a"
10324 + integrity sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==
10325 +
10326 jsdom@^20.0.0:
10327 version "20.0.3"
10328 resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db"
@@ -14385,6 +14397,11 @@ stackframe@^1.1.1:
14397 resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.1.1.tgz#ffef0a3318b1b60c3b58564989aca5660729ec71"
14398 integrity sha512-0PlYhdKh6AfFxRyK/v+6/k+/mMfyiEBbTM5L94D0ZytQnJ166wuwoTYLHFWGbs2dpA8Rgq763KGWmN1EQEYHRQ==
14399
14400 +stackframe@^1.3.4:
14401 + version "1.3.4"
14402 + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.3.4.tgz#b881a004c8c149a5e8efef37d51b16e412943310"
14403 + integrity sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==
14404 +
14405 static-extend@^0.1.1:
14406 version "0.1.2"
14407 resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"