@samitouri / QOS-React-2 / commits / 9fc04eaf3f

DevTools: Improve named hooks network caching (#22198)

While testing the recently-launched named hooks feature, I noticed that one of the two big performance bottlenecks is fetching the source file. This was unexpected since the source file has already been loaded by the page. (After all, DevTools is inspecting a component defined in that same file.) To address this, I made the following changes: - [x] Keep CPU bound work (parsing source map and AST) in a worker so it doesn't block the main thread but move I/O bound code (fetching files) to the main thread. - [x] Inject a function into the page (as part of the content script) to fetch cached files for the extension. Communicate with this function using `eval()` (to send it messages) and `chrome.runtime.sendMessage()` to return its responses to the extension). With the above changes in place, the extension gets cached responses from a lot of sites- but not Facebook. This seems to be due to the following: * Facebook's response headers include [`vary: 'Origin'`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary). * The `fetch` made from the content script does not include an `Origin` request header. To reduce the impact of cases where we can't re-use the Network cache, this PR also makes additional changes: - [x] Use `devtools.network.onRequestFinished` to (pre)cache resources as the page loads them. This allows us to avoid requesting a resource that's already been loaded in most cases. - [x] In case DevTools was opened _after_ some requests were made, we also now pre-fetch (and cache in memory) source files when a component is selected (if it has hooks). If the component's hooks are later evaluated, the source map will be faster to access. (Note that in many cases, this prefetch is very fast since it is returned from the disk cache.) With the above changes, we've reduced the time spent in `loadSourceFiles` to nearly nothing.

Brian Vaughn committed Sep 1, 2021 at 14:10 UTC 9fc04eaf3fb701cdc14f57d5aed48f3126af6c94
15 files changed +1364 -816
packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js
+49 -21
@@ -25,6 +25,23 @@ function requireText(path, encoding) {
25 }
26 }
27
28 +function initFetchMock() {
29 + const fetchMock = require('jest-fetch-mock');
30 + fetchMock.enableMocks();
31 + fetchMock.mockIf(/.+$/, request => {
32 + const url = request.url;
33 + const isLoadingExternalSourceMap = /external\/.*\.map/.test(url);
34 + if (isLoadingExternalSourceMap) {
35 + // Assert that url contains correct query params
36 + expect(url.includes('?foo=bar&param=some_value')).toBe(true);
37 + const fileSystemPath = url.split('?')[0];
38 + return requireText(fileSystemPath, 'utf8');
39 + }
40 + return requireText(url, 'utf8');
41 + });
42 + return fetchMock;
43 +}
44 +
45 describe('parseHookNames', () => {
46 let fetchMock;
47 let inspectHooks;
@@ -37,12 +54,32 @@ describe('parseHookNames', () => {
54 console.trace('source-map-support');
55 });
56
40 - fetchMock = require('jest-fetch-mock');
41 - fetchMock.enableMocks();
57 + fetchMock = initFetchMock();
58
59 inspectHooks = require('react-debug-tools/src/ReactDebugHooks')
60 .inspectHooks;
45 - parseHookNames = require('../parseHookNames/parseHookNames').parseHookNames;
61 +
62 + // Jest can't run the workerized version of this module.
63 + const {
64 + flattenHooksList,
65 + loadSourceAndMetadata,
66 + } = require('../parseHookNames/loadSourceAndMetadata');
67 + const parseSourceAndMetadata = require('../parseHookNames/parseSourceAndMetadata')
68 + .parseSourceAndMetadata;
69 + parseHookNames = async hooksTree => {
70 + const hooksList = flattenHooksList(hooksTree);
71 +
72 + // Runs in the UI thread so it can share Network cache:
73 + const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata(
74 + hooksList,
75 + );
76 +
77 + // Runs in a Worker because it's CPU intensive:
78 + return parseSourceAndMetadata(
79 + hooksList,
80 + locationKeyToHookSourceAndMetadata,
81 + );
82 + };
83
84 // Jest (jest-runner?) configures Errors to automatically account for source maps.
85 // This changes behavior between our tests and the browser.
@@ -55,18 +92,6 @@ describe('parseHookNames', () => {
92 Error.prepareStackTrace = (error, trace) => {
93 return error.stack;
94 };
58 -
59 - fetchMock.mockIf(/.+$/, request => {
60 - const url = request.url;
61 - const isLoadingExternalSourceMap = /external\/.*\.map/.test(url);
62 - if (isLoadingExternalSourceMap) {
63 - // Assert that url contains correct query params
64 - expect(url.includes('?foo=bar&param=some_value')).toBe(true);
65 - const fileSystemPath = url.split('?')[0];
66 - return requireText(fileSystemPath, 'utf8');
67 - }
68 - return requireText(url, 'utf8');
69 - });
95 });
96
97 afterEach(() => {
@@ -880,18 +905,20 @@ describe('parseHookNames', () => {
905 describe('parseHookNames worker', () => {
906 let inspectHooks;
907 let parseHookNames;
883 - let workerizedParseHookNamesMock;
908 + let workerizedParseSourceAndMetadataMock;
909
910 beforeEach(() => {
911 window.Worker = undefined;
912
888 - workerizedParseHookNamesMock = jest.fn();
913 + workerizedParseSourceAndMetadataMock = jest.fn();
914
890 - jest.mock('../parseHookNames/parseHookNames.worker.js', () => {
915 + initFetchMock();
916 +
917 + jest.mock('../parseHookNames/parseSourceAndMetadata.worker.js', () => {
918 return {
919 __esModule: true,
920 default: () => ({
894 - parseHookNames: workerizedParseHookNamesMock,
921 + parseSourceAndMetadata: workerizedParseSourceAndMetadataMock,
922 }),
923 };
924 });
@@ -912,11 +939,12 @@ describe('parseHookNames worker', () => {
939 .Component;
940
941 window.Worker = true;
915 - // resets module so mocked worker instance can be updated
942 +
943 + // Reset module so mocked worker instance can be updated.
944 jest.resetModules();
945 parseHookNames = require('../parseHookNames').parseHookNames;
946
947 await getHookNamesForComponent(Component);
920 - expect(workerizedParseHookNamesMock).toHaveBeenCalledTimes(1);
948 + expect(workerizedParseSourceAndMetadataMock).toHaveBeenCalledTimes(1);
949 });
950 });
packages/react-devtools-extensions/src/background.js
+15 -2
@@ -117,14 +117,27 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
117 });
118
119 chrome.runtime.onMessage.addListener((request, sender) => {
120 - if (sender.tab) {
120 + const tab = sender.tab;
121 + if (tab) {
122 + const id = tab.id;
123 // This is sent from the hook content script.
124 // It tells us a renderer has attached.
125 if (request.hasDetectedReact) {
126 // We use browserAction instead of pageAction because this lets us
127 // display a custom default popup when React is *not* detected.
128 // It is specified in the manifest.
127 - setIconAndPopup(request.reactBuildType, sender.tab.id);
129 + setIconAndPopup(request.reactBuildType, id);
130 + } else {
131 + switch (request.payload?.type) {
132 + case 'fetch-file-with-cache-complete':
133 + case 'fetch-file-with-cache-error':
134 + // Forward the result of fetch-in-page requests back to the extension.
135 + const devtools = ports[id]?.devtools;
136 + if (devtools) {
137 + devtools.postMessage(request);
138 + }
139 + break;
140 + }
141 }
142 }
143 });
packages/react-devtools-extensions/src/injectGlobalHook.js
+62 -17
@@ -23,21 +23,66 @@ let lastDetectionResult;
23 // (it will be injected directly into the page).
24 // So instead, the hook will use postMessage() to pass message to us here.
25 // And when this happens, we'll send a message to the "background page".
26 -window.addEventListener('message', function(evt) {
27 - if (evt.source !== window || !evt.data) {
26 +window.addEventListener('message', function onMessage({data, source}) {
27 + if (source !== window || !data) {
28 return;
29 }
30 - if (evt.data.source === 'react-devtools-detector') {
31 - lastDetectionResult = {
32 - hasDetectedReact: true,
33 - reactBuildType: evt.data.reactBuildType,
34 - };
35 - chrome.runtime.sendMessage(lastDetectionResult);
36 - } else if (evt.data.source === 'react-devtools-inject-backend') {
37 - const script = document.createElement('script');
38 - script.src = chrome.runtime.getURL('build/react_devtools_backend.js');
39 - document.documentElement.appendChild(script);
40 - script.parentNode.removeChild(script);
30 +
31 + switch (data.source) {
32 + case 'react-devtools-detector':
33 + lastDetectionResult = {
34 + hasDetectedReact: true,
35 + reactBuildType: data.reactBuildType,
36 + };
37 + chrome.runtime.sendMessage(lastDetectionResult);
38 + break;
39 + case 'react-devtools-extension':
40 + if (data.payload?.type === 'fetch-file-with-cache') {
41 + const url = data.payload.url;
42 +
43 + const reject = value => {
44 + chrome.runtime.sendMessage({
45 + source: 'react-devtools-content-script',
46 + payload: {
47 + type: 'fetch-file-with-cache-error',
48 + url,
49 + value,
50 + },
51 + });
52 + };
53 +
54 + const resolve = value => {
55 + chrome.runtime.sendMessage({
56 + source: 'react-devtools-content-script',
57 + payload: {
58 + type: 'fetch-file-with-cache-complete',
59 + url,
60 + value,
61 + },
62 + });
63 + };
64 +
65 + fetch(url, {cache: 'force-cache'}).then(
66 + response => {
67 + if (response.ok) {
68 + response
69 + .text()
70 + .then(text => resolve(text))
71 + .catch(error => reject(null));
72 + } else {
73 + reject(null);
74 + }
75 + },
76 + error => reject(null),
77 + );
78 + }
79 + break;
80 + case 'react-devtools-inject-backend':
81 + const script = document.createElement('script');
82 + script.src = chrome.runtime.getURL('build/react_devtools_backend.js');
83 + document.documentElement.appendChild(script);
84 + script.parentNode.removeChild(script);
85 + break;
86 }
87 });
88
@@ -45,18 +90,18 @@ window.addEventListener('message', function(evt) {
90 // while navigating the history to a document that has not been destroyed yet,
91 // replay the last detection result if the content script is active and the
92 // document has been hidden and shown again.
48 -window.addEventListener('pageshow', function(evt) {
49 - if (!lastDetectionResult || evt.target !== window.document) {
93 +window.addEventListener('pageshow', function({target}) {
94 + if (!lastDetectionResult || target !== window.document) {
95 return;
96 }
97 chrome.runtime.sendMessage(lastDetectionResult);
98 });
99
100 const detectReact = `
56 -window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function(evt) {
101 +window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function({reactBuildType}) {
102 window.postMessage({
103 source: 'react-devtools-detector',
59 - reactBuildType: evt.reactBuildType,
104 + reactBuildType,
105 }, '*');
106 });
107 `;
packages/react-devtools-extensions/src/main.js
+84 -1
@@ -25,6 +25,27 @@ const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY =
25
26 const isChrome = getBrowserName() === 'Chrome';
27
28 +const cachedNetworkEvents = new Map();
29 +
30 +// Cache JavaScript resources as the page loads them.
31 +// This helps avoid unnecessary duplicate requests when hook names are parsed.
32 +// Responses with a Vary: 'Origin' might not match future requests.
33 +// This lets us avoid a possible (expensive) cache miss.
34 +// For more info see: github.com/facebook/react/pull/22198
35 +chrome.devtools.network.onRequestFinished.addListener(
36 + function onRequestFinished(event) {
37 + if (event.request.method === 'GET') {
38 + switch (event.response.content.mimeType) {
39 + case 'application/javascript':
40 + case 'application/x-javascript':
41 + case 'text/javascript':
42 + cachedNetworkEvents.set(event.request.url, event);
43 + break;
44 + }
45 + }
46 + },
47 +);
48 +
49 let panelCreated = false;
50
51 // The renderer interface can't read saved component filters directly,
@@ -212,20 +233,76 @@ function createPanelIfReactLoaded() {
233 }
234 };
235
236 + // For some reason in Firefox, chrome.runtime.sendMessage() from a content script
237 + // never reaches the chrome.runtime.onMessage event listener.
238 + let fetchFileWithCaching = null;
239 + if (isChrome) {
240 + // Fetching files from the extension won't make use of the network cache
241 + // for resources that have already been loaded by the page.
242 + // This helper function allows the extension to request files to be fetched
243 + // by the content script (running in the page) to increase the likelihood of a cache hit.
244 + fetchFileWithCaching = url => {
245 + const event = cachedNetworkEvents.get(url);
246 + if (event != null) {
247 + // If this resource has already been cached locally,
248 + // skip the network queue (which might not be a cache hit anyway)
249 + // and just use the cached response.
250 + return new Promise(resolve => {
251 + event.getContent(content => resolve(content));
252 + });
253 + }
254 +
255 + // If DevTools was opened after the page started loading,
256 + // we may have missed some requests.
257 + // So fall back to a fetch() and hope we get a cached response.
258 +
259 + return new Promise((resolve, reject) => {
260 + function onPortMessage({payload, source}) {
261 + if (source === 'react-devtools-content-script') {
262 + switch (payload?.type) {
263 + case 'fetch-file-with-cache-complete':
264 + chrome.runtime.onMessage.removeListener(onPortMessage);
265 + resolve(payload.value);
266 + break;
267 + case 'fetch-file-with-cache-error':
268 + chrome.runtime.onMessage.removeListener(onPortMessage);
269 + reject(payload.value);
270 + break;
271 + }
272 + }
273 + }
274 +
275 + chrome.runtime.onMessage.addListener(onPortMessage);
276 +
277 + chrome.devtools.inspectedWindow.eval(`
278 + window.postMessage({
279 + source: 'react-devtools-extension',
280 + payload: {
281 + type: 'fetch-file-with-cache',
282 + url: "${url}",
283 + },
284 + });
285 + `);
286 + });
287 + };
288 + }
289 +
290 root = createRoot(document.createElement('div'));
291
292 render = (overrideTab = mostRecentOverrideTab) => {
293 mostRecentOverrideTab = overrideTab;
294 import('./parseHookNames').then(
220 - ({parseHookNames, purgeCachedMetadata}) => {
295 + ({parseHookNames, prefetchSourceFiles, purgeCachedMetadata}) => {
296 root.render(
297 createElement(DevTools, {
298 bridge,
299 browserTheme: getBrowserTheme(),
300 componentsPortalContainer,
301 enabledInspectedElementContextMenu: true,
302 + fetchFileWithCaching,
303 loadHookNames: parseHookNames,
304 overrideTab,
305 + prefetchSourceFiles,
306 profilerPortalContainer,
307 purgeCachedHookNamesMetadata: purgeCachedMetadata,
308 showTabBar: false,
@@ -366,6 +443,9 @@ function createPanelIfReactLoaded() {
443
444 // Re-initialize DevTools panel when a new page is loaded.
445 chrome.devtools.network.onNavigated.addListener(function onNavigated() {
446 + // Clear cached requests when a new page is opened.
447 + cachedNetworkEvents.clear();
448 +
449 // Re-initialize saved filters on navigation,
450 // since global values stored on window get reset in this case.
451 syncSavedPreferences();
@@ -382,6 +462,9 @@ function createPanelIfReactLoaded() {
462
463 // Load (or reload) the DevTools extension when the user navigates to a new page.
464 function checkPageForReact() {
465 + // Clear cached requests when a new page is opened.
466 + cachedNetworkEvents.clear();
467 +
468 syncSavedPreferences();
469 createPanelIfReactLoaded();
470 }
packages/react-devtools-extensions/src/parseHookNames/index.js
+50 -8
@@ -7,17 +7,59 @@
7 * @flow
8 */
9
10 -// This file uses workerize to load ./parseHookNames.worker as a webworker and instanciates it,
11 -// exposing flow typed functions that can be used on other files.
10 +import type {HookSourceAndMetadata} from './loadSourceAndMetadata';
11 +import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
12 +import type {HookNames} from 'react-devtools-shared/src/types';
13 +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools';
14
13 -import WorkerizedParseHookNames from './parseHookNames.worker';
14 -import typeof * as ParseHookNamesModule from './parseHookNames';
15 +import {withAsyncPerformanceMark} from 'react-devtools-shared/src/PerformanceMarks';
16 +import WorkerizedParseSourceAndMetadata from './parseSourceAndMetadata.worker';
17 +import typeof * as ParseSourceAndMetadataModule from './parseSourceAndMetadata';
18 +import {
19 + flattenHooksList,
20 + loadSourceAndMetadata,
21 + prefetchSourceFiles,
22 +} from './loadSourceAndMetadata';
23
16 -const workerizedParseHookNames: ParseHookNamesModule = WorkerizedParseHookNames();
24 +const workerizedParseHookNames: ParseSourceAndMetadataModule = WorkerizedParseSourceAndMetadata();
25
18 -type ParseHookNames = $PropertyType<ParseHookNamesModule, 'parseHookNames'>;
26 +export {prefetchSourceFiles};
27
20 -export const parseHookNames: ParseHookNames = hooksTree =>
21 - workerizedParseHookNames.parseHookNames(hooksTree);
28 +export function parseSourceAndMetadata(
29 + hooksList: Array<HooksNode>,
30 + locationKeyToHookSourceAndMetadata: Map<string, HookSourceAndMetadata>,
31 +): Promise<HookNames | null> {
32 + return workerizedParseHookNames.parseSourceAndMetadata(
33 + hooksList,
34 + locationKeyToHookSourceAndMetadata,
35 + );
36 +}
37
38 export const purgeCachedMetadata = workerizedParseHookNames.purgeCachedMetadata;
39 +
40 +const EMPTY_MAP = new Map();
41 +
42 +export async function parseHookNames(
43 + hooksTree: HooksTree,
44 + fetchFileWithCaching: FetchFileWithCaching | null,
45 +): Promise<HookNames | null> {
46 + return withAsyncPerformanceMark('parseHookNames', async () => {
47 + const hooksList = flattenHooksList(hooksTree);
48 + if (hooksList.length === 0) {
49 + // This component tree contains no named hooks.
50 + return EMPTY_MAP;
51 + }
52 +
53 + // Runs on the main/UI thread so it can reuse Network cache:
54 + const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata(
55 + hooksList,
56 + fetchFileWithCaching,
57 + );
58 +
59 + // Runs in a Worker because it's CPU intensive:
60 + return parseSourceAndMetadata(
61 + hooksList,
62 + locationKeyToHookSourceAndMetadata,
63 + );
64 + });
65 +}
packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js new
+570
@@ -0,0 +1,570 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +// Parsing source and source maps is done in a Web Worker
11 +// because parsing is CPU intensive and should not block the UI thread.
12 +//
13 +// Fetching source and source map files is intentionally done on the UI thread
14 +// so that loaded source files can reuse the browser's Network cache.
15 +// Requests made from within an extension do not share the page's Network cache,
16 +// but messages can be sent from the UI thread to the content script
17 +// which can make a request from the page's context (with caching).
18 +//
19 +// Some overhead may be incurred sharing (serializing) the loaded data between contexts,
20 +// but less than fetching the file to begin with,
21 +// and in some cases we can avoid serializing the source code at all
22 +// (e.g. when we are in an environment that supports our custom metadata format).
23 +//
24 +// The overall flow of this file is such:
25 +// 1. Find the Set of source files defining the hooks and load them all.
26 +// Then for each source file, do the following:
27 +//
28 +// a. Search loaded source file to see if a source map is available.
29 +// If so, load that file and pass it to a Worker for parsing.
30 +// The source map is used to retrieve the original source,
31 +// which is then also parsed in the Worker to infer hook names.
32 +// This is less ideal because parsing a full source map is slower,
33 +// since we need to evaluate the mappings in order to map the runtime code to the original source,
34 +// but at least the eventual source that we parse to an AST is small/fast.
35 +//
36 +// b. If no source map, pass the full source to a Worker for parsing.
37 +// Use the source to infer hook names.
38 +// This is the least optimal route as parsing the full source is very CPU intensive.
39 +//
40 +// In the future, we may add an additional optimization the above sequence.
41 +// This check would come before the source map check:
42 +//
43 +// a. Search loaded source file to see if a custom React metadata file is available.
44 +// If so, load that file and pass it to a Worker for parsing and extracting.
45 +// This is the fastest option since our custom metadata file is much smaller than a full source map,
46 +// and there is no need to convert runtime code to the original source.
47 +
48 +import LRU from 'lru-cache';
49 +import {__DEBUG__} from 'react-devtools-shared/src/constants';
50 +import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
51 +import {sourceMapIncludesSource} from '../SourceMapUtils';
52 +import {
53 + withAsyncPerformanceMark,
54 + withCallbackPerformanceMark,
55 + withSyncPerformanceMark,
56 +} from 'react-devtools-shared/src/PerformanceMarks';
57 +
58 +import type {LRUCache} from 'react-devtools-shared/src/types';
59 +import type {
60 + HooksNode,
61 + HookSource,
62 + HooksTree,
63 +} from 'react-debug-tools/src/ReactDebugHooks';
64 +import type {MixedSourceMap} from 'react-devtools-extensions/src/SourceMapTypes';
65 +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools';
66 +
67 +// Prefer a cached albeit stale response to reduce download time.
68 +// We wouldn't want to load/parse a newer version of the source (even if one existed).
69 +const FETCH_OPTIONS = {cache: 'force-cache'};
70 +
71 +const MAX_SOURCE_LENGTH = 100_000_000;
72 +
73 +// Fetch requests originated from an extension might not have origin headers
74 +// which may prevent subsequent requests from using cached responses
75 +// if the server returns a Vary: 'Origin' header
76 +// so this cache will temporarily store pre-fetches sources in memory.
77 +const prefetchedSources: LRUCache<string, string> = new LRU({
78 + max: 15,
79 +});
80 +
81 +export type HookSourceAndMetadata = {|
82 + // Generated by react-debug-tools.
83 + hookSource: HookSource,
84 +
85 + // Compiled code (React components or custom hooks) containing primitive hook calls.
86 + runtimeSourceCode: string | null,
87 +
88 + // Same as hookSource.fileName but guaranteed to be non-null.
89 + runtimeSourceURL: string,
90 +
91 + // Raw source map JSON.
92 + // Either decoded from an inline source map or loaded from an externa source map file.
93 + // Sources without source maps won't have this.
94 + sourceMapJSON: MixedSourceMap | null,
95 +
96 + // External URL of source map.
97 + // Sources without source maps (or with inline source maps) won't have this.
98 + sourceMapURL: string | null,
99 +|};
100 +
101 +export type LocationKeyToHookSourceAndMetadata = Map<
102 + string,
103 + HookSourceAndMetadata,
104 +>;
105 +export type HooksList = Array<HooksNode>;
106 +
107 +export async function loadSourceAndMetadata(
108 + hooksList: HooksList,
109 + fetchFileWithCaching: FetchFileWithCaching | null,
110 +): Promise<LocationKeyToHookSourceAndMetadata> {
111 + return withAsyncPerformanceMark('loadSourceAndMetadata()', async () => {
112 + const locationKeyToHookSourceAndMetadata = withSyncPerformanceMark(
113 + 'initializeHookSourceAndMetadata',
114 + () => initializeHookSourceAndMetadata(hooksList),
115 + );
116 +
117 + await withAsyncPerformanceMark('loadSourceFiles()', () =>
118 + loadSourceFiles(locationKeyToHookSourceAndMetadata, fetchFileWithCaching),
119 + );
120 +
121 + await withAsyncPerformanceMark('extractAndLoadSourceMapJSON()', () =>
122 + extractAndLoadSourceMapJSON(locationKeyToHookSourceAndMetadata),
123 + );
124 +
125 + // At this point, we've loaded JS source (text) and source map (JSON).
126 + // The remaining works (parsing these) is CPU intensive and should be done in a worker.
127 + return locationKeyToHookSourceAndMetadata;
128 + });
129 +}
130 +
131 +function decodeBase64String(encoded: string): Object {
132 + if (typeof atob === 'function') {
133 + return atob(encoded);
134 + } else if (
135 + typeof Buffer !== 'undefined' &&
136 + Buffer !== null &&
137 + typeof Buffer.from === 'function'
138 + ) {
139 + return Buffer.from(encoded, 'base64');
140 + } else {
141 + throw Error('Cannot decode base64 string');
142 + }
143 +}
144 +
145 +function extractAndLoadSourceMapJSON(
146 + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
147 +): Promise<*> {
148 + // Deduplicate fetches, since there can be multiple location keys per source map.
149 + const dedupedFetchPromises = new Map();
150 +
151 + const setterPromises = [];
152 + locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => {
153 + const sourceMapRegex = / ?sourceMappingURL=([^\s'"]+)/gm;
154 + const runtimeSourceCode = ((hookSourceAndMetadata.runtimeSourceCode: any): string);
155 +
156 + // TODO (named hooks) Search for our custom metadata first.
157 + // If it's found, we should use it rather than source maps.
158 +
159 + // TODO (named hooks) If this RegExp search is slow, we could try breaking it up
160 + // first using an indexOf(' sourceMappingURL=') to find the start of the comment
161 + // (probably at the end of the file) and then running the RegExp on the remaining substring.
162 + let sourceMappingURLMatch = withSyncPerformanceMark(
163 + 'sourceMapRegex.exec(runtimeSourceCode)',
164 + () => sourceMapRegex.exec(runtimeSourceCode),
165 + );
166 +
167 + if (sourceMappingURLMatch == null) {
168 + if (__DEBUG__) {
169 + console.log('extractAndLoadSourceMapJSON() No source map found');
170 + }
171 +
172 + // Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST().
173 + } else {
174 + const externalSourceMapURLs = [];
175 + while (sourceMappingURLMatch != null) {
176 + const {runtimeSourceURL} = hookSourceAndMetadata;
177 + const sourceMappingURL = sourceMappingURLMatch[1];
178 + const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0;
179 + if (hasInlineSourceMap) {
180 + // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch)
181 + // since there can be multiple location keys per source map.
182 +
183 + // Web apps like Code Sandbox embed multiple inline source maps.
184 + // In this case, we need to loop through and find the right one.
185 + // We may also need to trim any part of this string that isn't based64 encoded data.
186 + const trimmed = ((sourceMappingURL.match(
187 + /base64,([a-zA-Z0-9+\/=]+)/,
188 + ): any): Array<string>)[1];
189 + const decoded = withSyncPerformanceMark('decodeBase64String()', () =>
190 + decodeBase64String(trimmed),
191 + );
192 +
193 + const sourceMapJSON = withSyncPerformanceMark(
194 + 'JSON.parse(decoded)',
195 + () => JSON.parse(decoded),
196 + );
197 +
198 + if (__DEBUG__) {
199 + console.groupCollapsed(
200 + 'extractAndLoadSourceMapJSON() Inline source map',
201 + );
202 + console.log(sourceMapJSON);
203 + console.groupEnd();
204 + }
205 +
206 + // Hook source might be a URL like "https://4syus.csb.app/src/App.js"
207 + // Parsed source map might be a partial path like "src/App.js"
208 + if (sourceMapIncludesSource(sourceMapJSON, runtimeSourceURL)) {
209 + hookSourceAndMetadata.sourceMapJSON = sourceMapJSON;
210 +
211 + // OPTIMIZATION If we've located a source map for this source,
212 + // we'll use it to retrieve the original source (to extract hook names).
213 + // We only fall back to parsing the full source code is when there's no source map.
214 + // The source is (potentially) very large,
215 + // So we can avoid the overhead of serializing it unnecessarily.
216 + hookSourceAndMetadata.runtimeSourceCode = null;
217 +
218 + break;
219 + }
220 + } else {
221 + externalSourceMapURLs.push(sourceMappingURL);
222 + }
223 +
224 + // If the first source map we found wasn't a match, check for more.
225 + sourceMappingURLMatch = withSyncPerformanceMark(
226 + 'sourceMapRegex.exec(runtimeSourceCode)',
227 + () => sourceMapRegex.exec(runtimeSourceCode),
228 + );
229 + }
230 +
231 + if (hookSourceAndMetadata.sourceMapJSON === null) {
232 + externalSourceMapURLs.forEach((sourceMappingURL, index) => {
233 + if (index !== externalSourceMapURLs.length - 1) {
234 + // Files with external source maps should only have a single source map.
235 + // More than one result might indicate an edge case,
236 + // like a string in the source code that matched our "sourceMappingURL" regex.
237 + // We should just skip over cases like this.
238 + console.warn(
239 + `More than one external source map detected in the source file; skipping "${sourceMappingURL}"`,
240 + );
241 + return;
242 + }
243 +
244 + const {runtimeSourceURL} = hookSourceAndMetadata;
245 + let url = sourceMappingURL;
246 + if (!url.startsWith('http') && !url.startsWith('/')) {
247 + // Resolve paths relative to the location of the file name
248 + const lastSlashIdx = runtimeSourceURL.lastIndexOf('/');
249 + if (lastSlashIdx !== -1) {
250 + const baseURL = runtimeSourceURL.slice(
251 + 0,
252 + runtimeSourceURL.lastIndexOf('/'),
253 + );
254 + url = `${baseURL}/${url}`;
255 + }
256 + }
257 +
258 + hookSourceAndMetadata.sourceMapURL = url;
259 +
260 + const fetchPromise =
261 + dedupedFetchPromises.get(url) ||
262 + fetchFile(url).then(
263 + sourceMapContents => {
264 + const sourceMapJSON = withSyncPerformanceMark(
265 + 'JSON.parse(sourceMapContents)',
266 + () => JSON.parse(sourceMapContents),
267 + );
268 +
269 + return sourceMapJSON;
270 + },
271 +
272 + // In this case, we fall back to the assumption that the source has no source map.
273 + // This might indicate an (unlikely) edge case that had no source map,
274 + // but contained the string "sourceMappingURL".
275 + error => null,
276 + );
277 +
278 + if (__DEBUG__) {
279 + if (!dedupedFetchPromises.has(url)) {
280 + console.log(
281 + `extractAndLoadSourceMapJSON() External source map "${url}"`,
282 + );
283 + }
284 + }
285 +
286 + dedupedFetchPromises.set(url, fetchPromise);
287 +
288 + setterPromises.push(
289 + fetchPromise.then(sourceMapJSON => {
290 + if (sourceMapJSON !== null) {
291 + hookSourceAndMetadata.sourceMapJSON = sourceMapJSON;
292 +
293 + // OPTIMIZATION If we've located a source map for this source,
294 + // we'll use it to retrieve the original source (to extract hook names).
295 + // We only fall back to parsing the full source code is when there's no source map.
296 + // The source is (potentially) very large,
297 + // So we can avoid the overhead of serializing it unnecessarily.
298 + hookSourceAndMetadata.runtimeSourceCode = null;
299 + }
300 + }),
301 + );
302 + });
303 + }
304 + }
305 + });
306 +
307 + return Promise.all(setterPromises);
308 +}
309 +
310 +function fetchFile(
311 + url: string,
312 + markName?: string = 'fetchFile',
313 +): Promise<string> {
314 + return withCallbackPerformanceMark(`${markName}("${url}")`, done => {
315 + return new Promise((resolve, reject) => {
316 + fetch(url, FETCH_OPTIONS).then(
317 + response => {
318 + if (response.ok) {
319 + response
320 + .text()
321 + .then(text => {
322 + done();
323 + resolve(text);
324 + })
325 + .catch(error => {
326 + if (__DEBUG__) {
327 + console.log(
328 + `${markName}() Could not read text for url "${url}"`,
329 + );
330 + }
331 + done();
332 + reject(null);
333 + });
334 + } else {
335 + if (__DEBUG__) {
336 + console.log(`${markName}() Got bad response for url "${url}"`);
337 + }
338 + done();
339 + reject(null);
340 + }
341 + },
342 + error => {
343 + if (__DEBUG__) {
344 + console.log(`${markName}() Could not fetch file: ${error.message}`);
345 + }
346 + done();
347 + reject(null);
348 + },
349 + );
350 + });
351 + });
352 +}
353 +
354 +export function hasNamedHooks(hooksTree: HooksTree): boolean {
355 + for (let i = 0; i < hooksTree.length; i++) {
356 + const hook = hooksTree[i];
357 +
358 + if (!isUnnamedBuiltInHook(hook)) {
359 + return true;
360 + }
361 +
362 + if (hook.subHooks.length > 0) {
363 + if (hasNamedHooks(hook.subHooks)) {
364 + return true;
365 + }
366 + }
367 + }
368 +
369 + return false;
370 +}
371 +
372 +export function flattenHooksList(hooksTree: HooksTree): HooksList {
373 + const hooksList: HooksList = [];
374 + withSyncPerformanceMark('flattenHooksList()', () => {
375 + flattenHooksListImpl(hooksTree, hooksList);
376 + });
377 +
378 + if (__DEBUG__) {
379 + console.log('flattenHooksList() hooksList:', hooksList);
380 + }
381 +
382 + return hooksList;
383 +}
384 +
385 +function flattenHooksListImpl(
386 + hooksTree: HooksTree,
387 + hooksList: Array<HooksNode>,
388 +): void {
389 + for (let i = 0; i < hooksTree.length; i++) {
390 + const hook = hooksTree[i];
391 +
392 + if (isUnnamedBuiltInHook(hook)) {
393 + // No need to load source code or do any parsing for unnamed hooks.
394 + if (__DEBUG__) {
395 + console.log('flattenHooksListImpl() Skipping unnamed hook', hook);
396 + }
397 +
398 + continue;
399 + }
400 +
401 + hooksList.push(hook);
402 +
403 + if (hook.subHooks.length > 0) {
404 + flattenHooksListImpl(hook.subHooks, hooksList);
405 + }
406 + }
407 +}
408 +
409 +function initializeHookSourceAndMetadata(
410 + hooksList: Array<HooksNode>,
411 +): LocationKeyToHookSourceAndMetadata {
412 + // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
413 + const locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata = new Map();
414 + for (let i = 0; i < hooksList.length; i++) {
415 + const hook = hooksList[i];
416 +
417 + const hookSource = hook.hookSource;
418 + if (hookSource == null) {
419 + // Older versions of react-debug-tools don't include this information.
420 + // In this case, we can't continue.
421 + throw Error('Hook source code location not found.');
422 + }
423 +
424 + const locationKey = getHookSourceLocationKey(hookSource);
425 + if (!locationKeyToHookSourceAndMetadata.has(locationKey)) {
426 + // Can't be null because getHookSourceLocationKey() would have thrown
427 + const runtimeSourceURL = ((hookSource.fileName: any): string);
428 +
429 + const hookSourceAndMetadata: HookSourceAndMetadata = {
430 + hookSource,
431 + runtimeSourceCode: null,
432 + runtimeSourceURL,
433 + sourceMapJSON: null,
434 + sourceMapURL: null,
435 + };
436 +
437 + locationKeyToHookSourceAndMetadata.set(
438 + locationKey,
439 + hookSourceAndMetadata,
440 + );
441 + }
442 + }
443 +
444 + return locationKeyToHookSourceAndMetadata;
445 +}
446 +
447 +// Determines whether incoming hook is a primitive hook that gets assigned to variables.
448 +function isUnnamedBuiltInHook(hook: HooksNode) {
449 + return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes(
450 + hook.name,
451 + );
452 +}
453 +
454 +function loadSourceFiles(
455 + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
456 + fetchFileWithCaching: FetchFileWithCaching | null,
457 +): Promise<*> {
458 + // Deduplicate fetches, since there can be multiple location keys per file.
459 + const dedupedFetchPromises = new Map();
460 +
461 + const setterPromises = [];
462 + locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => {
463 + const {runtimeSourceURL} = hookSourceAndMetadata;
464 +
465 + const prefetchedSourceCode = prefetchedSources.get(runtimeSourceURL);
466 + if (prefetchedSourceCode != null) {
467 + hookSourceAndMetadata.runtimeSourceCode = prefetchedSourceCode;
468 + } else {
469 + let fetchFileFunction = fetchFile;
470 + if (fetchFileWithCaching != null) {
471 + // If a helper function has been injected to fetch with caching,
472 + // use it to fetch the (already loaded) source file.
473 + fetchFileFunction = url => {
474 + return withAsyncPerformanceMark(
475 + `fetchFileWithCaching("${url}")`,
476 + () => {
477 + return ((fetchFileWithCaching: any): FetchFileWithCaching)(url);
478 + },
479 + );
480 + };
481 + }
482 +
483 + const fetchPromise =
484 + dedupedFetchPromises.get(runtimeSourceURL) ||
485 + fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => {
486 + // TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps,
487 + // because then we need to parse the full source file as an AST.
488 + if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
489 + throw Error('Source code too large to parse');
490 + }
491 +
492 + if (__DEBUG__) {
493 + console.groupCollapsed(
494 + `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
495 + );
496 + console.log(runtimeSourceCode);
497 + console.groupEnd();
498 + }
499 +
500 + return runtimeSourceCode;
501 + });
502 + dedupedFetchPromises.set(runtimeSourceURL, fetchPromise);
503 +
504 + setterPromises.push(
505 + fetchPromise.then(runtimeSourceCode => {
506 + hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode;
507 + }),
508 + );
509 + }
510 + });
511 +
512 + return Promise.all(setterPromises);
513 +}
514 +
515 +export function prefetchSourceFiles(
516 + hooksTree: HooksTree,
517 + fetchFileWithCaching: FetchFileWithCaching | null,
518 +): void {
519 + // Deduplicate fetches, since there can be multiple location keys per source map.
520 + const dedupedFetchPromises = new Set();
521 +
522 + let fetchFileFunction = null;
523 + if (fetchFileWithCaching != null) {
524 + // If a helper function has been injected to fetch with caching,
525 + // use it to fetch the (already loaded) source file.
526 + fetchFileFunction = url => {
527 + return withAsyncPerformanceMark(
528 + `[pre] fetchFileWithCaching("${url}")`,
529 + () => {
530 + return ((fetchFileWithCaching: any): FetchFileWithCaching)(url);
531 + },
532 + );
533 + };
534 + } else {
535 + fetchFileFunction = url => fetchFile(url, '[pre] fetchFile');
536 + }
537 +
538 + const hooksQueue = Array.from(hooksTree);
539 +
540 + for (let i = 0; i < hooksQueue.length; i++) {
541 + const hook = hooksQueue.pop();
542 + if (isUnnamedBuiltInHook(hook)) {
543 + continue;
544 + }
545 +
546 + const hookSource = hook.hookSource;
547 + if (hookSource == null) {
548 + continue;
549 + }
550 +
551 + const runtimeSourceURL = ((hookSource.fileName: any): string);
552 +
553 + if (prefetchedSources.has(runtimeSourceURL)) {
554 + // If we've already fetched this source, skip it.
555 + continue;
556 + }
557 +
558 + if (!dedupedFetchPromises.has(runtimeSourceURL)) {
559 + dedupedFetchPromises.add(runtimeSourceURL);
560 +
561 + fetchFileFunction(runtimeSourceURL).then(text => {
562 + prefetchedSources.set(runtimeSourceURL, text);
563 + });
564 + }
565 +
566 + if (hook.subHooks.length > 0) {
567 + hooksQueue.push(...hook.subHooks);
568 + }
569 + }
570 +}
packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js deleted
-750
@@ -1,750 +0,0 @@
1 -/**
2 - * Copyright (c) Facebook, Inc. and its 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 {parse} from '@babel/parser';
11 -import LRU from 'lru-cache';
12 -import {SourceMapConsumer} from 'source-map-js';
13 -import {getHookName} from '../astUtils';
14 -import {areSourceMapsAppliedToErrors} from '../ErrorTester';
15 -import {__DEBUG__} from 'react-devtools-shared/src/constants';
16 -import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
17 -import {sourceMapIncludesSource} from '../SourceMapUtils';
18 -import {SourceMapMetadataConsumer} from '../SourceMapMetadataConsumer';
19 -import {
20 - withAsyncPerformanceMark,
21 - withCallbackPerformanceMark,
22 - withSyncPerformanceMark,
23 -} from 'react-devtools-shared/src/PerformanceMarks';
24 -
25 -import type {
26 - HooksNode,
27 - HookSource,
28 - HooksTree,
29 -} from 'react-debug-tools/src/ReactDebugHooks';
30 -import type {HookNames, LRUCache} from 'react-devtools-shared/src/types';
31 -import type {SourceConsumer} from '../astUtils';
32 -
33 -const MAX_SOURCE_LENGTH = 100_000_000;
34 -
35 -type AST = mixed;
36 -
37 -type HookSourceData = {|
38 - // Generated by react-debug-tools.
39 - hookSource: HookSource,
40 -
41 - // API for consuming metadfata present in extended source map.
42 - metadataConsumer: SourceMapMetadataConsumer | null,
43 -
44 - // AST for original source code; typically comes from a consumed source map.
45 - originalSourceAST: AST | null,
46 -
47 - // Source code (React components or custom hooks) containing primitive hook calls.
48 - // If no source map has been provided, this code will be the same as runtimeSourceCode.
49 - originalSourceCode: string | null,
50 -
51 - // Original source URL if there is a source map, or the same as runtimeSourceURL.
52 - originalSourceURL: string | null,
53 -
54 - // Compiled code (React components or custom hooks) containing primitive hook calls.
55 - runtimeSourceCode: string | null,
56 -
57 - // Same as hookSource.fileName but guaranteed to be non-null.
58 - runtimeSourceURL: string,
59 -
60 - // APIs from source-map for parsing source maps (if detected).
61 - sourceConsumer: SourceConsumer | null,
62 -
63 - // External URL of source map.
64 - // Sources without source maps (or with inline source maps) won't have this.
65 - sourceMapURL: string | null,
66 -|};
67 -
68 -type CachedRuntimeCodeMetadata = {|
69 - sourceConsumer: SourceConsumer | null,
70 - metadataConsumer: SourceMapMetadataConsumer | null,
71 -|};
72 -
73 -const runtimeURLToMetadataCache: LRUCache<
74 - string,
75 - CachedRuntimeCodeMetadata,
76 -> = new LRU({
77 - max: 50,
78 - dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => {
79 - if (__DEBUG__) {
80 - console.log(
81 - `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`,
82 - );
83 - }
84 -
85 - const sourceConsumer = metadata.sourceConsumer;
86 - if (sourceConsumer !== null) {
87 - sourceConsumer.destroy();
88 - }
89 - },
90 -});
91 -
92 -type CachedSourceCodeMetadata = {|
93 - originalSourceAST: AST,
94 - originalSourceCode: string,
95 -|};
96 -
97 -const originalURLToMetadataCache: LRUCache<
98 - string,
99 - CachedSourceCodeMetadata,
100 -> = new LRU({
101 - max: 50,
102 - dispose: (originalSourceURL: string, metadata: CachedSourceCodeMetadata) => {
103 - if (__DEBUG__) {
104 - console.log(
105 - `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`,
106 - );
107 - }
108 - },
109 -});
110 -
111 -export async function parseHookNames(
112 - hooksTree: HooksTree,
113 -): Promise<HookNames | null> {
114 - const hooksList: Array<HooksNode> = [];
115 - withSyncPerformanceMark('flattenHooksList()', () => {
116 - flattenHooksList(hooksTree, hooksList);
117 - });
118 -
119 - return withAsyncPerformanceMark('parseHookNames()', () =>
120 - parseHookNamesImpl(hooksList),
121 - );
122 -}
123 -
124 -async function parseHookNamesImpl(
125 - hooksList: HooksNode[],
126 -): Promise<HookNames | null> {
127 - if (__DEBUG__) {
128 - console.log('parseHookNames() hooksList:', hooksList);
129 - }
130 -
131 - // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
132 - const locationKeyToHookSourceData: Map<string, HookSourceData> = new Map();
133 - for (let i = 0; i < hooksList.length; i++) {
134 - const hook = hooksList[i];
135 -
136 - const hookSource = hook.hookSource;
137 - if (hookSource == null) {
138 - // Older versions of react-debug-tools don't include this information.
139 - // In this case, we can't continue.
140 - throw Error('Hook source code location not found.');
141 - }
142 -
143 - const locationKey = getHookSourceLocationKey(hookSource);
144 - if (!locationKeyToHookSourceData.has(locationKey)) {
145 - // Can't be null because getHookSourceLocationKey() would have thrown
146 - const runtimeSourceURL = ((hookSource.fileName: any): string);
147 -
148 - const hookSourceData: HookSourceData = {
149 - hookSource,
150 - metadataConsumer: null,
151 - originalSourceAST: null,
152 - originalSourceCode: null,
153 - originalSourceURL: null,
154 - runtimeSourceCode: null,
155 - runtimeSourceURL,
156 - sourceConsumer: null,
157 - sourceMapURL: null,
158 - };
159 -
160 - // If we've already loaded the source map info for this file,
161 - // we can skip reloading it (and more importantly, re-parsing it).
162 - const runtimeMetadata = runtimeURLToMetadataCache.get(
163 - hookSourceData.runtimeSourceURL,
164 - );
165 - if (runtimeMetadata != null) {
166 - if (__DEBUG__) {
167 - console.groupCollapsed(
168 - `parseHookNames() Found cached runtime metadata for file "${hookSourceData.runtimeSourceURL}"`,
169 - );
170 - console.log(runtimeMetadata);
171 - console.groupEnd();
172 - }
173 - hookSourceData.sourceConsumer = runtimeMetadata.sourceConsumer;
174 - hookSourceData.metadataConsumer = runtimeMetadata.metadataConsumer;
175 - }
176 -
177 - locationKeyToHookSourceData.set(locationKey, hookSourceData);
178 - }
179 - }
180 -
181 - await withAsyncPerformanceMark('loadSourceFiles()', () =>
182 - loadSourceFiles(locationKeyToHookSourceData),
183 - );
184 -
185 - await withAsyncPerformanceMark('extractAndLoadSourceMaps()', () =>
186 - extractAndLoadSourceMaps(locationKeyToHookSourceData),
187 - );
188 -
189 - withSyncPerformanceMark('parseSourceAST()', () =>
190 - parseSourceAST(locationKeyToHookSourceData),
191 - );
192 -
193 - withSyncPerformanceMark('updateLruCache()', () =>
194 - updateLruCache(locationKeyToHookSourceData),
195 - );
196 -
197 - return withSyncPerformanceMark('findHookNames()', () =>
198 - findHookNames(hooksList, locationKeyToHookSourceData),
199 - );
200 -}
201 -
202 -function decodeBase64String(encoded: string): Object {
203 - if (typeof atob === 'function') {
204 - return atob(encoded);
205 - } else if (
206 - typeof Buffer !== 'undefined' &&
207 - Buffer !== null &&
208 - typeof Buffer.from === 'function'
209 - ) {
210 - return Buffer.from(encoded, 'base64');
211 - } else {
212 - throw Error('Cannot decode base64 string');
213 - }
214 -}
215 -
216 -function extractAndLoadSourceMaps(
217 - locationKeyToHookSourceData: Map<string, HookSourceData>,
218 -): Promise<*> {
219 - // SourceMapConsumer.initialize() does nothing when running in Node (aka Jest)
220 - // because the wasm file is automatically read from the file system
221 - // so we can avoid triggering a warning message about this.
222 - if (!__TEST__) {
223 - if (__DEBUG__) {
224 - console.log(
225 - 'extractAndLoadSourceMaps() Initializing source-map library ...',
226 - );
227 - }
228 - }
229 -
230 - // Deduplicate fetches, since there can be multiple location keys per source map.
231 - const fetchPromises = new Map();
232 -
233 - const setPromises = [];
234 - locationKeyToHookSourceData.forEach(hookSourceData => {
235 - if (
236 - hookSourceData.sourceConsumer != null &&
237 - hookSourceData.metadataConsumer != null
238 - ) {
239 - // Use cached source map and metadata consumers.
240 - return;
241 - }
242 -
243 - const sourceMapRegex = / ?sourceMappingURL=([^\s'"]+)/gm;
244 - const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string);
245 -
246 - let sourceMappingURLMatch = withSyncPerformanceMark(
247 - 'sourceMapRegex.exec(runtimeSourceCode)',
248 - () => sourceMapRegex.exec(runtimeSourceCode),
249 - );
250 -
251 - if (sourceMappingURLMatch == null) {
252 - // Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST().
253 -
254 - if (__DEBUG__) {
255 - console.log('extractAndLoadSourceMaps() No source map found');
256 - }
257 - } else {
258 - const externalSourceMapURLs = [];
259 - while (sourceMappingURLMatch != null) {
260 - const {runtimeSourceURL} = hookSourceData;
261 - const sourceMappingURL = sourceMappingURLMatch[1];
262 - const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0;
263 - if (hasInlineSourceMap) {
264 - // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch)
265 - // since there can be multiple location keys per source map.
266 -
267 - // Web apps like Code Sandbox embed multiple inline source maps.
268 - // In this case, we need to loop through and find the right one.
269 - // We may also need to trim any part of this string that isn't based64 encoded data.
270 - const trimmed = ((sourceMappingURL.match(
271 - /base64,([a-zA-Z0-9+\/=]+)/,
272 - ): any): Array<string>)[1];
273 - const decoded = withSyncPerformanceMark('decodeBase64String()', () =>
274 - decodeBase64String(trimmed),
275 - );
276 -
277 - const parsed = withSyncPerformanceMark('JSON.parse(decoded)', () =>
278 - JSON.parse(decoded),
279 - );
280 -
281 - if (__DEBUG__) {
282 - console.groupCollapsed(
283 - 'extractAndLoadSourceMaps() Inline source map',
284 - );
285 - console.log(parsed);
286 - console.groupEnd();
287 - }
288 -
289 - // Hook source might be a URL like "https://4syus.csb.app/src/App.js"
290 - // Parsed source map might be a partial path like "src/App.js"
291 - if (sourceMapIncludesSource(parsed, runtimeSourceURL)) {
292 - hookSourceData.metadataConsumer = withSyncPerformanceMark(
293 - 'new SourceMapMetadataConsumer(parsed)',
294 - () => new SourceMapMetadataConsumer(parsed),
295 - );
296 - hookSourceData.sourceConsumer = withSyncPerformanceMark(
297 - 'new SourceMapConsumer(parsed)',
298 - () => new SourceMapConsumer(parsed),
299 - );
300 - break;
301 - }
302 - } else {
303 - externalSourceMapURLs.push(sourceMappingURL);
304 - }
305 -
306 - sourceMappingURLMatch = withSyncPerformanceMark(
307 - 'sourceMapRegex.exec(runtimeSourceCode)',
308 - () => sourceMapRegex.exec(runtimeSourceCode),
309 - );
310 - }
311 -
312 - const foundInlineSourceMap =
313 - hookSourceData.sourceConsumer != null &&
314 - hookSourceData.metadataConsumer != null;
315 - if (!foundInlineSourceMap) {
316 - externalSourceMapURLs.forEach((sourceMappingURL, index) => {
317 - if (index !== externalSourceMapURLs.length - 1) {
318 - // Files with external source maps should only have a single source map.
319 - // More than one result might indicate an edge case,
320 - // like a string in the source code that matched our "sourceMappingURL" regex.
321 - // We should just skip over cases like this.
322 - console.warn(
323 - `More than one external source map detected in the source file; skipping "${sourceMappingURL}"`,
324 - );
325 - return;
326 - }
327 -
328 - const {runtimeSourceURL} = hookSourceData;
329 - let url = sourceMappingURL;
330 - if (!url.startsWith('http') && !url.startsWith('/')) {
331 - // Resolve paths relative to the location of the file name
332 - const lastSlashIdx = runtimeSourceURL.lastIndexOf('/');
333 - if (lastSlashIdx !== -1) {
334 - const baseURL = runtimeSourceURL.slice(
335 - 0,
336 - runtimeSourceURL.lastIndexOf('/'),
337 - );
338 - url = `${baseURL}/${url}`;
339 - }
340 - }
341 -
342 - hookSourceData.sourceMapURL = url;
343 -
344 - const fetchPromise =
345 - fetchPromises.get(url) ||
346 - fetchFile(url).then(
347 - sourceMapContents => {
348 - const parsed = withSyncPerformanceMark(
349 - 'JSON.parse(sourceMapContents)',
350 - () => JSON.parse(sourceMapContents),
351 - );
352 -
353 - const sourceConsumer = withSyncPerformanceMark(
354 - 'new SourceMapConsumer(parsed)',
355 - () => new SourceMapConsumer(parsed),
356 - );
357 -
358 - const metadataConsumer = withSyncPerformanceMark(
359 - 'new SourceMapMetadataConsumer(parsed)',
360 - () => new SourceMapMetadataConsumer(parsed),
361 - );
362 -
363 - return {sourceConsumer, metadataConsumer};
364 - },
365 - // In this case, we fall back to the assumption that the source has no source map.
366 - // This might indicate an (unlikely) edge case that had no source map,
367 - // but contained the string "sourceMappingURL".
368 - error => null,
369 - );
370 -
371 - if (__DEBUG__) {
372 - if (!fetchPromises.has(url)) {
373 - console.log(
374 - `extractAndLoadSourceMaps() External source map "${url}"`,
375 - );
376 - }
377 - }
378 -
379 - fetchPromises.set(url, fetchPromise);
380 - setPromises.push(
381 - fetchPromise.then(result => {
382 - hookSourceData.metadataConsumer =
383 - result?.metadataConsumer ?? null;
384 - hookSourceData.sourceConsumer = result?.sourceConsumer ?? null;
385 - }),
386 - );
387 - });
388 - }
389 - }
390 - });
391 - return Promise.all(setPromises);
392 -}
393 -
394 -function fetchFile(url: string): Promise<string> {
395 - return withCallbackPerformanceMark('fetchFile("' + url + '")', done => {
396 - return new Promise((resolve, reject) => {
397 - fetch(url).then(
398 - response => {
399 - if (response.ok) {
400 - response
401 - .text()
402 - .then(text => {
403 - done();
404 - resolve(text);
405 - })
406 - .catch(error => {
407 - if (__DEBUG__) {
408 - console.log(
409 - `fetchFile() Could not read text for url "${url}"`,
410 - );
411 - }
412 - done();
413 - reject(null);
414 - });
415 - } else {
416 - if (__DEBUG__) {
417 - console.log(`fetchFile() Got bad response for url "${url}"`);
418 - }
419 - done();
420 - reject(null);
421 - }
422 - },
423 - error => {
424 - if (__DEBUG__) {
425 - console.log(`fetchFile() Could not fetch file: ${error.message}`);
426 - }
427 - done();
428 - reject(null);
429 - },
430 - );
431 - });
432 - });
433 -}
434 -
435 -function findHookNames(
436 - hooksList: Array<HooksNode>,
437 - locationKeyToHookSourceData: Map<string, HookSourceData>,
438 -): HookNames {
439 - const map: HookNames = new Map();
440 -
441 - hooksList.map(hook => {
442 - // We already guard against a null HookSource in parseHookNames()
443 - const hookSource = ((hook.hookSource: any): HookSource);
444 - const fileName = hookSource.fileName;
445 - if (!fileName) {
446 - return null; // Should not be reachable.
447 - }
448 -
449 - const locationKey = getHookSourceLocationKey(hookSource);
450 - const hookSourceData = locationKeyToHookSourceData.get(locationKey);
451 - if (!hookSourceData) {
452 - return null; // Should not be reachable.
453 - }
454 -
455 - const {lineNumber, columnNumber} = hookSource;
456 - if (!lineNumber || !columnNumber) {
457 - return null; // Should not be reachable.
458 - }
459 -
460 - const {originalSourceURL, sourceConsumer} = hookSourceData;
461 -
462 - let originalSourceColumnNumber;
463 - let originalSourceLineNumber;
464 - if (areSourceMapsAppliedToErrors() || !sourceConsumer) {
465 - // Either the current environment automatically applies source maps to errors,
466 - // or the current code had no source map to begin with.
467 - // Either way, we don't need to convert the Error stack frame locations.
468 - originalSourceColumnNumber = columnNumber;
469 - originalSourceLineNumber = lineNumber;
470 - } else {
471 - const position = withSyncPerformanceMark(
472 - 'sourceConsumer.originalPositionFor()',
473 - () =>
474 - sourceConsumer.originalPositionFor({
475 - line: lineNumber,
476 -
477 - // Column numbers are represented differently between tools/engines.
478 - // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
479 - // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
480 - column: columnNumber - 1,
481 - }),
482 - );
483 -
484 - originalSourceColumnNumber = position.column;
485 - originalSourceLineNumber = position.line;
486 - }
487 -
488 - if (__DEBUG__) {
489 - console.log(
490 - `findHookNames() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`,
491 - );
492 - }
493 -
494 - if (
495 - originalSourceLineNumber == null ||
496 - originalSourceColumnNumber == null ||
497 - originalSourceURL == null
498 - ) {
499 - return null;
500 - }
501 -
502 - let name;
503 - const {metadataConsumer} = hookSourceData;
504 - if (metadataConsumer != null) {
505 - name = withSyncPerformanceMark('metadataConsumer.hookNameFor()', () =>
506 - metadataConsumer.hookNameFor({
507 - line: originalSourceLineNumber,
508 - column: originalSourceColumnNumber,
509 - source: originalSourceURL,
510 - }),
511 - );
512 - }
513 -
514 - if (name == null) {
515 - name = withSyncPerformanceMark('getHookName()', () =>
516 - getHookName(
517 - hook,
518 - hookSourceData.originalSourceAST,
519 - ((hookSourceData.originalSourceCode: any): string),
520 - ((originalSourceLineNumber: any): number),
521 - originalSourceColumnNumber,
522 - ),
523 - );
524 - }
525 -
526 - if (__DEBUG__) {
527 - console.log(`findHookNames() Found name "${name || '-'}"`);
528 - }
529 -
530 - const key = getHookSourceLocationKey(hookSource);
531 - map.set(key, name);
532 - });
533 -
534 - return map;
535 -}
536 -
537 -function loadSourceFiles(
538 - locationKeyToHookSourceData: Map<string, HookSourceData>,
539 -): Promise<*> {
540 - // Deduplicate fetches, since there can be multiple location keys per file.
541 - const fetchPromises = new Map();
542 -
543 - const setPromises = [];
544 - locationKeyToHookSourceData.forEach(hookSourceData => {
545 - const {runtimeSourceURL} = hookSourceData;
546 - const fetchPromise =
547 - fetchPromises.get(runtimeSourceURL) ||
548 - fetchFile(runtimeSourceURL).then(runtimeSourceCode => {
549 - if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
550 - throw Error('Source code too large to parse');
551 - }
552 - if (__DEBUG__) {
553 - console.groupCollapsed(
554 - `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
555 - );
556 - console.log(runtimeSourceCode);
557 - console.groupEnd();
558 - }
559 - return runtimeSourceCode;
560 - });
561 - fetchPromises.set(runtimeSourceURL, fetchPromise);
562 - setPromises.push(
563 - fetchPromise.then(runtimeSourceCode => {
564 - hookSourceData.runtimeSourceCode = runtimeSourceCode;
565 - }),
566 - );
567 - });
568 - return Promise.all(setPromises);
569 -}
570 -
571 -function parseSourceAST(
572 - locationKeyToHookSourceData: Map<string, HookSourceData>,
573 -): void {
574 - locationKeyToHookSourceData.forEach(hookSourceData => {
575 - if (hookSourceData.originalSourceAST !== null) {
576 - // Use cached metadata.
577 - return;
578 - }
579 -
580 - const {metadataConsumer, sourceConsumer} = hookSourceData;
581 - const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string);
582 - let hasHookMap = false;
583 - let originalSourceURL;
584 - let originalSourceCode;
585 - if (sourceConsumer !== null) {
586 - // Parse and extract the AST from the source map.
587 - const {lineNumber, columnNumber} = hookSourceData.hookSource;
588 - if (lineNumber == null || columnNumber == null) {
589 - throw Error('Hook source code location not found.');
590 - }
591 - // Now that the source map has been loaded,
592 - // extract the original source for later.
593 - const {source} = withSyncPerformanceMark(
594 - 'sourceConsumer.originalPositionFor()',
595 - () =>
596 - sourceConsumer.originalPositionFor({
597 - line: lineNumber,
598 -
599 - // Column numbers are represented differently between tools/engines.
600 - // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
601 - // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
602 - column: columnNumber - 1,
603 - }),
604 - );
605 -
606 - if (source == null) {
607 - // TODO (named hooks) maybe fall back to the runtime source instead of throwing?
608 - throw new Error(
609 - 'Could not map hook runtime location to original source location',
610 - );
611 - }
612 -
613 - // TODO (named hooks) maybe canonicalize this URL somehow?
614 - // It can be relative if the source map specifies it that way,
615 - // but we use it as a cache key across different source maps and there can be collisions.
616 - originalSourceURL = (source: string);
617 - originalSourceCode = withSyncPerformanceMark(
618 - 'sourceConsumer.sourceContentFor()',
619 - () => (sourceConsumer.sourceContentFor(source, true): string),
620 - );
621 -
622 - if (__DEBUG__) {
623 - console.groupCollapsed(
624 - 'parseSourceAST() Extracted source code from source map',
625 - );
626 - console.log(originalSourceCode);
627 - console.groupEnd();
628 - }
629 -
630 - if (
631 - metadataConsumer != null &&
632 - metadataConsumer.hasHookMap(originalSourceURL)
633 - ) {
634 - hasHookMap = true;
635 - }
636 - } else {
637 - // There's no source map to parse here so we can just parse the original source itself.
638 - originalSourceCode = runtimeSourceCode;
639 - // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space.
640 - // Namespace them?
641 - originalSourceURL = hookSourceData.runtimeSourceURL;
642 - }
643 -
644 - hookSourceData.originalSourceCode = originalSourceCode;
645 - hookSourceData.originalSourceURL = originalSourceURL;
646 -
647 - if (hasHookMap) {
648 - // If there's a hook map present from an extended sourcemap then
649 - // we don't need to parse the source files and instead can use the
650 - // hook map to extract hook names.
651 - return;
652 - }
653 -
654 - // The cache also serves to deduplicate parsing by URL in our loop over
655 - // location keys. This may need to change if we switch to async parsing.
656 - const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL);
657 - if (sourceMetadata != null) {
658 - if (__DEBUG__) {
659 - console.groupCollapsed(
660 - `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`,
661 - );
662 - console.log(sourceMetadata);
663 - console.groupEnd();
664 - }
665 - hookSourceData.originalSourceAST = sourceMetadata.originalSourceAST;
666 - hookSourceData.originalSourceCode = sourceMetadata.originalSourceCode;
667 - } else {
668 - // TypeScript is the most commonly used typed JS variant so let's default to it
669 - // unless we detect explicit Flow usage via the "@flow" pragma.
670 - const plugin =
671 - originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript';
672 -
673 - // TODO (named hooks) Parsing should ideally be done off of the main thread.
674 - const originalSourceAST = withSyncPerformanceMark(
675 - '[@babel/parser] parse(originalSourceCode)',
676 - () =>
677 - parse(originalSourceCode, {
678 - sourceType: 'unambiguous',
679 - plugins: ['jsx', plugin],
680 - }),
681 - );
682 - hookSourceData.originalSourceAST = originalSourceAST;
683 - if (__DEBUG__) {
684 - console.log(
685 - `parseSourceAST() Caching source metadata for "${originalSourceURL}"`,
686 - );
687 - }
688 - originalURLToMetadataCache.set(originalSourceURL, {
689 - originalSourceAST,
690 - originalSourceCode,
691 - });
692 - }
693 - });
694 -}
695 -
696 -function flattenHooksList(
697 - hooksTree: HooksTree,
698 - hooksList: Array<HooksNode>,
699 -): void {
700 - for (let i = 0; i < hooksTree.length; i++) {
701 - const hook = hooksTree[i];
702 -
703 - if (isUnnamedBuiltInHook(hook)) {
704 - // No need to load source code or do any parsing for unnamed hooks.
705 - if (__DEBUG__) {
706 - console.log('flattenHooksList() Skipping unnamed hook', hook);
707 - }
708 - continue;
709 - }
710 -
711 - hooksList.push(hook);
712 - if (hook.subHooks.length > 0) {
713 - flattenHooksList(hook.subHooks, hooksList);
714 - }
715 - }
716 -}
717 -
718 -// Determines whether incoming hook is a primitive hook that gets assigned to variables.
719 -function isUnnamedBuiltInHook(hook: HooksNode) {
720 - return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes(
721 - hook.name,
722 - );
723 -}
724 -
725 -function updateLruCache(
726 - locationKeyToHookSourceData: Map<string, HookSourceData>,
727 -): void {
728 - locationKeyToHookSourceData.forEach(
729 - ({metadataConsumer, sourceConsumer, runtimeSourceURL}) => {
730 - // Only set once to avoid triggering eviction/cleanup code.
731 - if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) {
732 - if (__DEBUG__) {
733 - console.log(
734 - `updateLruCache() Caching runtime metadata for "${runtimeSourceURL}"`,
735 - );
736 - }
737 -
738 - runtimeURLToMetadataCache.set(runtimeSourceURL, {
739 - metadataConsumer,
740 - sourceConsumer,
741 - });
742 - }
743 - },
744 - );
745 -}
746 -
747 -export function purgeCachedMetadata(): void {
748 - originalURLToMetadataCache.reset();
749 - runtimeURLToMetadataCache.reset();
750 -}
packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js deleted
-11
@@ -1,11 +0,0 @@
1 -/**
2 - * Copyright (c) Facebook, Inc. and its 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 -import * as parseHookNamesModule from './parseHookNames';
9 -
10 -export const parseHookNames = parseHookNamesModule.parseHookNames;
11 -export const purgeCachedMetadata = parseHookNamesModule.purgeCachedMetadata;
packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js new
+458
@@ -0,0 +1,458 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +// For an overview of why the code in this file is structured this way,
11 +// refer to header comments in loadSourceAndMetadata.
12 +
13 +import {parse} from '@babel/parser';
14 +import LRU from 'lru-cache';
15 +import {SourceMapConsumer} from 'source-map-js';
16 +import {getHookName} from '../astUtils';
17 +import {areSourceMapsAppliedToErrors} from '../ErrorTester';
18 +import {__DEBUG__} from 'react-devtools-shared/src/constants';
19 +import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
20 +import {SourceMapMetadataConsumer} from '../SourceMapMetadataConsumer';
21 +import {
22 + withAsyncPerformanceMark,
23 + withSyncPerformanceMark,
24 +} from 'react-devtools-shared/src/PerformanceMarks';
25 +
26 +import type {
27 + HooksList,
28 + LocationKeyToHookSourceAndMetadata,
29 +} from './loadSourceAndMetadata';
30 +import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks';
31 +import type {HookNames, LRUCache} from 'react-devtools-shared/src/types';
32 +import type {SourceConsumer} from '../astUtils';
33 +
34 +type AST = mixed;
35 +
36 +type HookParsedMetadata = {|
37 + // API for consuming metadfata present in extended source map.
38 + metadataConsumer: SourceMapMetadataConsumer | null,
39 +
40 + // AST for original source code; typically comes from a consumed source map.
41 + originalSourceAST: AST | null,
42 +
43 + // Source code (React components or custom hooks) containing primitive hook calls.
44 + // If no source map has been provided, this code will be the same as runtimeSourceCode.
45 + originalSourceCode: string | null,
46 +
47 + // Original source URL if there is a source map, or the same as runtimeSourceURL.
48 + originalSourceURL: string | null,
49 +
50 + // APIs from source-map for parsing source maps (if detected).
51 + sourceConsumer: SourceConsumer | null,
52 +|};
53 +
54 +type LocationKeyToHookParsedMetadata = Map<string, HookParsedMetadata>;
55 +
56 +type CachedRuntimeCodeMetadata = {|
57 + sourceConsumer: SourceConsumer | null,
58 + metadataConsumer: SourceMapMetadataConsumer | null,
59 +|};
60 +
61 +const runtimeURLToMetadataCache: LRUCache<
62 + string,
63 + CachedRuntimeCodeMetadata,
64 +> = new LRU({
65 + max: 50,
66 + dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => {
67 + if (__DEBUG__) {
68 + console.log(
69 + `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`,
70 + );
71 + }
72 +
73 + const sourceConsumer = metadata.sourceConsumer;
74 + if (sourceConsumer !== null) {
75 + sourceConsumer.destroy();
76 + }
77 + },
78 +});
79 +
80 +type CachedSourceCodeMetadata = {|
81 + originalSourceAST: AST,
82 + originalSourceCode: string,
83 +|};
84 +
85 +const originalURLToMetadataCache: LRUCache<
86 + string,
87 + CachedSourceCodeMetadata,
88 +> = new LRU({
89 + max: 50,
90 + dispose: (originalSourceURL: string, metadata: CachedSourceCodeMetadata) => {
91 + if (__DEBUG__) {
92 + console.log(
93 + `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`,
94 + );
95 + }
96 + },
97 +});
98 +
99 +export async function parseSourceAndMetadata(
100 + hooksList: HooksList,
101 + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
102 +): Promise<HookNames | null> {
103 + return withAsyncPerformanceMark('parseSourceAndMetadata()', async () => {
104 + const locationKeyToHookParsedMetadata = withSyncPerformanceMark(
105 + 'initializeHookParsedMetadata',
106 + () => initializeHookParsedMetadata(locationKeyToHookSourceAndMetadata),
107 + );
108 +
109 + withSyncPerformanceMark('parseSourceMaps', () =>
110 + parseSourceMaps(
111 + locationKeyToHookSourceAndMetadata,
112 + locationKeyToHookParsedMetadata,
113 + ),
114 + );
115 +
116 + withSyncPerformanceMark('parseSourceAST()', () =>
117 + parseSourceAST(
118 + locationKeyToHookSourceAndMetadata,
119 + locationKeyToHookParsedMetadata,
120 + ),
121 + );
122 +
123 + return withSyncPerformanceMark('findHookNames()', () =>
124 + findHookNames(hooksList, locationKeyToHookParsedMetadata),
125 + );
126 + });
127 +}
128 +
129 +function findHookNames(
130 + hooksList: HooksList,
131 + locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
132 +): HookNames {
133 + const map: HookNames = new Map();
134 +
135 + hooksList.map(hook => {
136 + // We already guard against a null HookSource in parseHookNames()
137 + const hookSource = ((hook.hookSource: any): HookSource);
138 + const fileName = hookSource.fileName;
139 + if (!fileName) {
140 + return null; // Should not be reachable.
141 + }
142 +
143 + const locationKey = getHookSourceLocationKey(hookSource);
144 + const hookParsedMetadata = locationKeyToHookParsedMetadata.get(locationKey);
145 + if (!hookParsedMetadata) {
146 + return null; // Should not be reachable.
147 + }
148 +
149 + const {lineNumber, columnNumber} = hookSource;
150 + if (!lineNumber || !columnNumber) {
151 + return null; // Should not be reachable.
152 + }
153 +
154 + const {originalSourceURL, sourceConsumer} = hookParsedMetadata;
155 +
156 + let originalSourceColumnNumber;
157 + let originalSourceLineNumber;
158 + if (areSourceMapsAppliedToErrors() || !sourceConsumer) {
159 + // Either the current environment automatically applies source maps to errors,
160 + // or the current code had no source map to begin with.
161 + // Either way, we don't need to convert the Error stack frame locations.
162 + originalSourceColumnNumber = columnNumber;
163 + originalSourceLineNumber = lineNumber;
164 + } else {
165 + // TODO (named hooks) Refactor this read, github.com/facebook/react/pull/22181
166 + const position = withSyncPerformanceMark(
167 + 'sourceConsumer.originalPositionFor()',
168 + () =>
169 + sourceConsumer.originalPositionFor({
170 + line: lineNumber,
171 +
172 + // Column numbers are represented differently between tools/engines.
173 + // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
174 + // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
175 + column: columnNumber - 1,
176 + }),
177 + );
178 +
179 + originalSourceColumnNumber = position.column;
180 + originalSourceLineNumber = position.line;
181 + }
182 +
183 + if (__DEBUG__) {
184 + console.log(
185 + `findHookNames() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`,
186 + );
187 + }
188 +
189 + if (
190 + originalSourceLineNumber == null ||
191 + originalSourceColumnNumber == null ||
192 + originalSourceURL == null
193 + ) {
194 + return null;
195 + }
196 +
197 + let name;
198 + const {metadataConsumer} = hookParsedMetadata;
199 + if (metadataConsumer != null) {
200 + name = withSyncPerformanceMark('metadataConsumer.hookNameFor()', () =>
201 + metadataConsumer.hookNameFor({
202 + line: originalSourceLineNumber,
203 + column: originalSourceColumnNumber,
204 + source: originalSourceURL,
205 + }),
206 + );
207 + }
208 +
209 + if (name == null) {
210 + name = withSyncPerformanceMark('getHookName()', () =>
211 + getHookName(
212 + hook,
213 + hookParsedMetadata.originalSourceAST,
214 + ((hookParsedMetadata.originalSourceCode: any): string),
215 + ((originalSourceLineNumber: any): number),
216 + originalSourceColumnNumber,
217 + ),
218 + );
219 + }
220 +
221 + if (__DEBUG__) {
222 + console.log(`findHookNames() Found name "${name || '-'}"`);
223 + }
224 +
225 + const key = getHookSourceLocationKey(hookSource);
226 + map.set(key, name);
227 + });
228 +
229 + return map;
230 +}
231 +
232 +function initializeHookParsedMetadata(
233 + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
234 +) {
235 + // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
236 + const locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata = new Map();
237 + locationKeyToHookSourceAndMetadata.forEach(
238 + (hookSourceAndMetadata, locationKey) => {
239 + const hookParsedMetadata: HookParsedMetadata = {
240 + metadataConsumer: null,
241 + originalSourceAST: null,
242 + originalSourceCode: null,
243 + originalSourceURL: null,
244 + sourceConsumer: null,
245 + };
246 +
247 + locationKeyToHookParsedMetadata.set(locationKey, hookParsedMetadata);
248 +
249 + const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL;
250 +
251 + // If we've already loaded the source map info for this file,
252 + // we can skip reloading it (and more importantly, re-parsing it).
253 + const runtimeMetadata = runtimeURLToMetadataCache.get(runtimeSourceURL);
254 + if (runtimeMetadata != null) {
255 + if (__DEBUG__) {
256 + console.groupCollapsed(
257 + `parseHookNames() Found cached runtime metadata for file "${runtimeSourceURL}"`,
258 + );
259 + console.log(runtimeMetadata);
260 + console.groupEnd();
261 + }
262 + hookParsedMetadata.sourceConsumer = runtimeMetadata.sourceConsumer;
263 + hookParsedMetadata.metadataConsumer = runtimeMetadata.metadataConsumer;
264 + }
265 + },
266 + );
267 +
268 + return locationKeyToHookParsedMetadata;
269 +}
270 +
271 +function parseSourceAST(
272 + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
273 + locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
274 +): void {
275 + locationKeyToHookSourceAndMetadata.forEach(
276 + (hookSourceAndMetadata, locationKey) => {
277 + const hookParsedMetadata = locationKeyToHookParsedMetadata.get(
278 + locationKey,
279 + );
280 + if (hookParsedMetadata == null) {
281 + throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`);
282 + }
283 +
284 + if (hookParsedMetadata.originalSourceAST !== null) {
285 + // Use cached metadata.
286 + return;
287 + }
288 +
289 + const {metadataConsumer, sourceConsumer} = hookParsedMetadata;
290 + const runtimeSourceCode = ((hookSourceAndMetadata.runtimeSourceCode: any): string);
291 + let hasHookMap = false;
292 + let originalSourceURL;
293 + let originalSourceCode;
294 + if (sourceConsumer !== null) {
295 + // Parse and extract the AST from the source map.
296 + const {lineNumber, columnNumber} = hookSourceAndMetadata.hookSource;
297 + if (lineNumber == null || columnNumber == null) {
298 + throw Error('Hook source code location not found.');
299 + }
300 + // Now that the source map has been loaded,
301 + // extract the original source for later.
302 + // TODO (named hooks) Refactor this read, github.com/facebook/react/pull/22181
303 + const {source} = withSyncPerformanceMark(
304 + 'sourceConsumer.originalPositionFor()',
305 + () =>
306 + sourceConsumer.originalPositionFor({
307 + line: lineNumber,
308 +
309 + // Column numbers are represented differently between tools/engines.
310 + // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
311 + // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
312 + column: columnNumber - 1,
313 + }),
314 + );
315 +
316 + if (source == null) {
317 + // TODO (named hooks) maybe fall back to the runtime source instead of throwing?
318 + throw new Error(
319 + 'Could not map hook runtime location to original source location',
320 + );
321 + }
322 +
323 + // TODO (named hooks) maybe canonicalize this URL somehow?
324 + // It can be relative if the source map specifies it that way,
325 + // but we use it as a cache key across different source maps and there can be collisions.
326 + originalSourceURL = (source: string);
327 + originalSourceCode = withSyncPerformanceMark(
328 + 'sourceConsumer.sourceContentFor()',
329 + () => (sourceConsumer.sourceContentFor(source, true): string),
330 + );
331 +
332 + if (__DEBUG__) {
333 + console.groupCollapsed(
334 + 'parseSourceAST() Extracted source code from source map',
335 + );
336 + console.log(originalSourceCode);
337 + console.groupEnd();
338 + }
339 +
340 + if (
341 + metadataConsumer != null &&
342 + metadataConsumer.hasHookMap(originalSourceURL)
343 + ) {
344 + hasHookMap = true;
345 + }
346 + } else {
347 + // There's no source map to parse here so we can just parse the original source itself.
348 + originalSourceCode = runtimeSourceCode;
349 + // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space.
350 + // Namespace them?
351 + originalSourceURL = hookSourceAndMetadata.runtimeSourceURL;
352 + }
353 +
354 + hookParsedMetadata.originalSourceCode = originalSourceCode;
355 + hookParsedMetadata.originalSourceURL = originalSourceURL;
356 +
357 + if (hasHookMap) {
358 + // If there's a hook map present from an extended sourcemap then
359 + // we don't need to parse the source files and instead can use the
360 + // hook map to extract hook names.
361 + return;
362 + }
363 +
364 + // The cache also serves to deduplicate parsing by URL in our loop over location keys.
365 + // This may need to change if we switch to async parsing.
366 + const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL);
367 + if (sourceMetadata != null) {
368 + if (__DEBUG__) {
369 + console.groupCollapsed(
370 + `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`,
371 + );
372 + console.log(sourceMetadata);
373 + console.groupEnd();
374 + }
375 + hookParsedMetadata.originalSourceAST = sourceMetadata.originalSourceAST;
376 + hookParsedMetadata.originalSourceCode =
377 + sourceMetadata.originalSourceCode;
378 + } else {
379 + // TypeScript is the most commonly used typed JS variant so let's default to it
380 + // unless we detect explicit Flow usage via the "@flow" pragma.
381 + const plugin =
382 + originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript';
383 +
384 + // TODO (named hooks) This is probably where we should check max source length,
385 + // rather than in loadSourceAndMetatada -> loadSourceFiles().
386 + const originalSourceAST = withSyncPerformanceMark(
387 + '[@babel/parser] parse(originalSourceCode)',
388 + () =>
389 + parse(originalSourceCode, {
390 + sourceType: 'unambiguous',
391 + plugins: ['jsx', plugin],
392 + }),
393 + );
394 + hookParsedMetadata.originalSourceAST = originalSourceAST;
395 +
396 + if (__DEBUG__) {
397 + console.log(
398 + `parseSourceAST() Caching source metadata for "${originalSourceURL}"`,
399 + );
400 + }
401 +
402 + originalURLToMetadataCache.set(originalSourceURL, {
403 + originalSourceAST,
404 + originalSourceCode,
405 + });
406 + }
407 + },
408 + );
409 +}
410 +
411 +function parseSourceMaps(
412 + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
413 + locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
414 +) {
415 + locationKeyToHookSourceAndMetadata.forEach(
416 + (hookSourceAndMetadata, locationKey) => {
417 + const hookParsedMetadata = locationKeyToHookParsedMetadata.get(
418 + locationKey,
419 + );
420 + if (hookParsedMetadata == null) {
421 + throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`);
422 + }
423 +
424 + const sourceMapJSON = hookSourceAndMetadata.sourceMapJSON;
425 + if (sourceMapJSON != null) {
426 + hookParsedMetadata.metadataConsumer = withSyncPerformanceMark(
427 + 'new SourceMapMetadataConsumer(sourceMapJSON)',
428 + () => new SourceMapMetadataConsumer(sourceMapJSON),
429 + );
430 + hookParsedMetadata.sourceConsumer = withSyncPerformanceMark(
431 + 'new SourceMapConsumer(sourceMapJSON)',
432 + () => new SourceMapConsumer(sourceMapJSON),
433 + );
434 +
435 + const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL;
436 +
437 + // Only set once to avoid triggering eviction/cleanup code.
438 + if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) {
439 + if (__DEBUG__) {
440 + console.log(
441 + `parseSourceMaps() Caching runtime metadata for "${runtimeSourceURL}"`,
442 + );
443 + }
444 +
445 + runtimeURLToMetadataCache.set(runtimeSourceURL, {
446 + metadataConsumer: hookParsedMetadata.metadataConsumer,
447 + sourceConsumer: hookParsedMetadata.sourceConsumer,
448 + });
449 + }
450 + }
451 + },
452 + );
453 +}
454 +
455 +export function purgeCachedMetadata(): void {
456 + originalURLToMetadataCache.reset();
457 + runtimeURLToMetadataCache.reset();
458 +}
packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js new
+13
@@ -0,0 +1,13 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +import * as parseSourceAndMetadataModule from './parseSourceAndMetadata';
9 +
10 +export const parseSourceAndMetadata =
11 + parseSourceAndMetadataModule.parseSourceAndMetadata;
12 +export const purgeCachedMetadata =
13 + parseSourceAndMetadataModule.purgeCachedMetadata;
packages/react-devtools-shared/src/PerformanceMarks.js
+12 -3
@@ -9,13 +9,22 @@
9
10 import {__PERFORMANCE_PROFILE__} from './constants';
11
12 +const supportsUserTiming =
13 + typeof performance !== 'undefined' &&
14 + typeof performance.mark === 'function' &&
15 + typeof performance.clearMarks === 'function';
16 +
17 function mark(markName: string): void {
13 - performance.mark(markName + '-start');
18 + if (supportsUserTiming) {
19 + performance.mark(markName + '-start');
20 + }
21 }
22
23 function measure(markName: string): void {
17 - performance.mark(markName + '-end');
18 - performance.measure(markName, markName + '-start', markName + '-end');
24 + if (supportsUserTiming) {
25 + performance.mark(markName + '-end');
26 + performance.measure(markName, markName + '-start', markName + '-end');
27 + }
28 }
29
30 export async function withAsyncPerformanceMark<TReturn>(
packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js
+6
@@ -2,17 +2,23 @@
2
3 import {createContext} from 'react';
4 import type {
5 + FetchFileWithCaching,
6 LoadHookNamesFunction,
7 + PrefetchSourceFiles,
8 PurgeCachedHookNamesMetadata,
9 } from '../DevTools';
10
11 export type Context = {
12 + fetchFileWithCaching: FetchFileWithCaching | null,
13 loadHookNames: LoadHookNamesFunction | null,
14 + prefetchSourceFiles: PrefetchSourceFiles | null,
15 purgeCachedMetadata: PurgeCachedHookNamesMetadata | null,
16 };
17
18 const HookNamesContext = createContext<Context>({
19 + fetchFileWithCaching: null,
20 loadHookNames: null,
21 + prefetchSourceFiles: null,
22 purgeCachedMetadata: null,
23 });
24 HookNamesContext.displayName = 'HookNamesContext';
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js
+19
@@ -16,6 +16,7 @@ import {
16 useContext,
17 useEffect,
18 useMemo,
19 + useRef,
20 useState,
21 } from 'react';
22 import {TreeStateContext} from './TreeContext';
@@ -64,7 +65,9 @@ export type Props = {|
65 export function InspectedElementContextController({children}: Props) {
66 const {selectedElementID} = useContext(TreeStateContext);
67 const {
68 + fetchFileWithCaching,
69 loadHookNames: loadHookNamesFunction,
70 + prefetchSourceFiles,
71 purgeCachedMetadata,
72 } = useContext(HookNamesContext);
73 const bridge = useContext(BridgeContext);
@@ -126,6 +129,7 @@ export function InspectedElementContextController({children}: Props) {
129 element,
130 inspectedElement.hooks,
131 loadHookNamesFunction,
132 + fetchFileWithCaching,
133 );
134 }
135 }
@@ -151,6 +155,21 @@ export function InspectedElementContextController({children}: Props) {
155 [setState, state],
156 );
157
158 + const inspectedElementRef = useRef(null);
159 + useEffect(() => {
160 + if (
161 + inspectedElement !== null &&
162 + inspectedElement.hooks !== null &&
163 + inspectedElementRef.current !== inspectedElement
164 + ) {
165 + inspectedElementRef.current = inspectedElement;
166 +
167 + if (typeof prefetchSourceFiles === 'function') {
168 + prefetchSourceFiles(inspectedElement.hooks, fetchFileWithCaching);
169 + }
170 + }
171 + }, [inspectedElement, prefetchSourceFiles]);
172 +
173 useEffect(() => {
174 if (typeof purgeCachedMetadata === 'function') {
175 // When Fast Refresh updates a component, any cached AST metadata may be invalid.
packages/react-devtools-shared/src/devtools/views/DevTools.js
+17 -1
@@ -51,6 +51,11 @@ import type {Thenable} from '../cache';
51 export type BrowserTheme = 'dark' | 'light';
52 export type TabID = 'components' | 'profiler';
53
54 +export type FetchFileWithCaching = (url: string) => Promise<string>;
55 +export type PrefetchSourceFiles = (
56 + hooksTree: HooksTree,
57 + fetchFileWithCaching: FetchFileWithCaching | null,
58 +) => void;
59 export type ViewElementSource = (
60 id: number,
61 inspectedElement: InspectedElement,
@@ -101,7 +106,9 @@ export type Props = {|
106 // Loads and parses source maps for function components
107 // and extracts hook "names" based on the variables the hook return values get assigned to.
108 // Not every DevTools build can load source maps, so this property is optional.
109 + fetchFileWithCaching?: ?FetchFileWithCaching,
110 loadHookNames?: ?LoadHookNamesFunction,
111 + prefetchSourceFiles?: ?PrefetchSourceFiles,
112 purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata,
113 |};
114
@@ -127,9 +134,11 @@ export default function DevTools({
134 componentsPortalContainer,
135 defaultTab = 'components',
136 enabledInspectedElementContextMenu = false,
137 + fetchFileWithCaching,
138 loadHookNames,
139 overrideTab,
140 profilerPortalContainer,
141 + prefetchSourceFiles,
142 purgeCachedHookNamesMetadata,
143 showTabBar = false,
144 store,
@@ -192,10 +201,17 @@ export default function DevTools({
201
202 const hookNamesContext = useMemo(
203 () => ({
204 + fetchFileWithCaching: fetchFileWithCaching || null,
205 loadHookNames: loadHookNames || null,
206 + prefetchSourceFiles: prefetchSourceFiles || null,
207 purgeCachedMetadata: purgeCachedHookNamesMetadata || null,
208 }),
198 - [loadHookNames, purgeCachedHookNamesMetadata],
209 + [
210 + fetchFileWithCaching,
211 + loadHookNames,
212 + prefetchSourceFiles,
213 + purgeCachedHookNamesMetadata,
214 + ],
215 );
216
217 const devToolsRef = useRef<HTMLElement | null>(null);
packages/react-devtools-shared/src/hookNamesCache.js
+9 -2
@@ -17,6 +17,7 @@ import type {
17 HookSourceLocationKey,
18 } from 'react-devtools-shared/src/types';
19 import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks';
20 +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools';
21
22 const TIMEOUT = 30000;
23
@@ -53,6 +54,11 @@ function readRecord<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord {
54 }
55 }
56
57 +type LoadHookNamesFunction = (
58 + hookLog: HooksTree,
59 + fetchFileWithCaching: FetchFileWithCaching | null,
60 +) => Thenable<HookNames>;
61 +
62 // This is intentionally a module-level Map, rather than a React-managed one.
63 // Otherwise, refreshing the inspected element cache would also clear this cache.
64 // TODO Rethink this if the React API constraints change.
@@ -67,7 +73,8 @@ export function hasAlreadyLoadedHookNames(element: Element): boolean {
73 export function loadHookNames(
74 element: Element,
75 hooksTree: HooksTree,
70 - loadHookNamesFunction: (hookLog: HooksTree) => Thenable<HookNames>,
76 + loadHookNamesFunction: LoadHookNamesFunction,
77 + fetchFileWithCaching: FetchFileWithCaching | null,
78 ): HookNames | null {
79 let record = map.get(element);
80
@@ -103,7 +110,7 @@ export function loadHookNames(
110
111 let didTimeout = false;
112
106 - loadHookNamesFunction(hooksTree).then(
113 + loadHookNamesFunction(hooksTree, fetchFileWithCaching).then(
114 function onSuccess(hookNames) {
115 if (didTimeout) {
116 return;