From c89f0942491bd5e55c013558aa19f68e1e74ce27 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 1 Sep 2021 14:10:07 -0400 Subject: [PATCH] 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. --- .../src/__tests__/parseHookNames-test.js | 70 +- .../src/background.js | 17 +- .../src/injectGlobalHook.js | 79 +- .../react-devtools-extensions/src/main.js | 85 +- .../src/parseHookNames/index.js | 58 +- .../parseHookNames/loadSourceAndMetadata.js | 570 +++++++++++++ .../src/parseHookNames/parseHookNames.js | 750 ------------------ .../parseHookNames/parseHookNames.worker.js | 11 - .../parseHookNames/parseSourceAndMetadata.js | 458 +++++++++++ .../parseSourceAndMetadata.worker.js | 13 + .../src/PerformanceMarks.js | 15 +- .../views/Components/HookNamesContext.js | 6 + .../Components/InspectedElementContext.js | 19 + .../src/devtools/views/DevTools.js | 18 +- .../src/hookNamesCache.js | 11 +- 15 files changed, 1364 insertions(+), 816 deletions(-) create mode 100644 packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js delete mode 100644 packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js delete mode 100644 packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js create mode 100644 packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js create mode 100644 packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js diff --git a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js index dd500bbfb2eaa..842cb78c05728 100644 --- a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js +++ b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js @@ -25,6 +25,23 @@ function requireText(path, encoding) { } } +function initFetchMock() { + const fetchMock = require('jest-fetch-mock'); + fetchMock.enableMocks(); + fetchMock.mockIf(/.+$/, request => { + const url = request.url; + const isLoadingExternalSourceMap = /external\/.*\.map/.test(url); + if (isLoadingExternalSourceMap) { + // Assert that url contains correct query params + expect(url.includes('?foo=bar¶m=some_value')).toBe(true); + const fileSystemPath = url.split('?')[0]; + return requireText(fileSystemPath, 'utf8'); + } + return requireText(url, 'utf8'); + }); + return fetchMock; +} + describe('parseHookNames', () => { let fetchMock; let inspectHooks; @@ -37,12 +54,32 @@ describe('parseHookNames', () => { console.trace('source-map-support'); }); - fetchMock = require('jest-fetch-mock'); - fetchMock.enableMocks(); + fetchMock = initFetchMock(); inspectHooks = require('react-debug-tools/src/ReactDebugHooks') .inspectHooks; - parseHookNames = require('../parseHookNames/parseHookNames').parseHookNames; + + // Jest can't run the workerized version of this module. + const { + flattenHooksList, + loadSourceAndMetadata, + } = require('../parseHookNames/loadSourceAndMetadata'); + const parseSourceAndMetadata = require('../parseHookNames/parseSourceAndMetadata') + .parseSourceAndMetadata; + parseHookNames = async hooksTree => { + const hooksList = flattenHooksList(hooksTree); + + // Runs in the UI thread so it can share Network cache: + const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata( + hooksList, + ); + + // Runs in a Worker because it's CPU intensive: + return parseSourceAndMetadata( + hooksList, + locationKeyToHookSourceAndMetadata, + ); + }; // Jest (jest-runner?) configures Errors to automatically account for source maps. // This changes behavior between our tests and the browser. @@ -55,18 +92,6 @@ describe('parseHookNames', () => { Error.prepareStackTrace = (error, trace) => { return error.stack; }; - - fetchMock.mockIf(/.+$/, request => { - const url = request.url; - const isLoadingExternalSourceMap = /external\/.*\.map/.test(url); - if (isLoadingExternalSourceMap) { - // Assert that url contains correct query params - expect(url.includes('?foo=bar¶m=some_value')).toBe(true); - const fileSystemPath = url.split('?')[0]; - return requireText(fileSystemPath, 'utf8'); - } - return requireText(url, 'utf8'); - }); }); afterEach(() => { @@ -880,18 +905,20 @@ describe('parseHookNames', () => { describe('parseHookNames worker', () => { let inspectHooks; let parseHookNames; - let workerizedParseHookNamesMock; + let workerizedParseSourceAndMetadataMock; beforeEach(() => { window.Worker = undefined; - workerizedParseHookNamesMock = jest.fn(); + workerizedParseSourceAndMetadataMock = jest.fn(); - jest.mock('../parseHookNames/parseHookNames.worker.js', () => { + initFetchMock(); + + jest.mock('../parseHookNames/parseSourceAndMetadata.worker.js', () => { return { __esModule: true, default: () => ({ - parseHookNames: workerizedParseHookNamesMock, + parseSourceAndMetadata: workerizedParseSourceAndMetadataMock, }), }; }); @@ -912,11 +939,12 @@ describe('parseHookNames worker', () => { .Component; window.Worker = true; - // resets module so mocked worker instance can be updated + + // Reset module so mocked worker instance can be updated. jest.resetModules(); parseHookNames = require('../parseHookNames').parseHookNames; await getHookNamesForComponent(Component); - expect(workerizedParseHookNamesMock).toHaveBeenCalledTimes(1); + expect(workerizedParseSourceAndMetadataMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react-devtools-extensions/src/background.js b/packages/react-devtools-extensions/src/background.js index cfe5e3c7a9738..9e09513b78fb4 100644 --- a/packages/react-devtools-extensions/src/background.js +++ b/packages/react-devtools-extensions/src/background.js @@ -117,14 +117,27 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { }); chrome.runtime.onMessage.addListener((request, sender) => { - if (sender.tab) { + const tab = sender.tab; + if (tab) { + const id = tab.id; // This is sent from the hook content script. // It tells us a renderer has attached. if (request.hasDetectedReact) { // We use browserAction instead of pageAction because this lets us // display a custom default popup when React is *not* detected. // It is specified in the manifest. - setIconAndPopup(request.reactBuildType, sender.tab.id); + setIconAndPopup(request.reactBuildType, id); + } else { + switch (request.payload?.type) { + case 'fetch-file-with-cache-complete': + case 'fetch-file-with-cache-error': + // Forward the result of fetch-in-page requests back to the extension. + const devtools = ports[id]?.devtools; + if (devtools) { + devtools.postMessage(request); + } + break; + } } } }); diff --git a/packages/react-devtools-extensions/src/injectGlobalHook.js b/packages/react-devtools-extensions/src/injectGlobalHook.js index e34197af35499..701d4927487d9 100644 --- a/packages/react-devtools-extensions/src/injectGlobalHook.js +++ b/packages/react-devtools-extensions/src/injectGlobalHook.js @@ -23,21 +23,66 @@ let lastDetectionResult; // (it will be injected directly into the page). // So instead, the hook will use postMessage() to pass message to us here. // And when this happens, we'll send a message to the "background page". -window.addEventListener('message', function(evt) { - if (evt.source !== window || !evt.data) { +window.addEventListener('message', function onMessage({data, source}) { + if (source !== window || !data) { return; } - if (evt.data.source === 'react-devtools-detector') { - lastDetectionResult = { - hasDetectedReact: true, - reactBuildType: evt.data.reactBuildType, - }; - chrome.runtime.sendMessage(lastDetectionResult); - } else if (evt.data.source === 'react-devtools-inject-backend') { - const script = document.createElement('script'); - script.src = chrome.runtime.getURL('build/react_devtools_backend.js'); - document.documentElement.appendChild(script); - script.parentNode.removeChild(script); + + switch (data.source) { + case 'react-devtools-detector': + lastDetectionResult = { + hasDetectedReact: true, + reactBuildType: data.reactBuildType, + }; + chrome.runtime.sendMessage(lastDetectionResult); + break; + case 'react-devtools-extension': + if (data.payload?.type === 'fetch-file-with-cache') { + const url = data.payload.url; + + const reject = value => { + chrome.runtime.sendMessage({ + source: 'react-devtools-content-script', + payload: { + type: 'fetch-file-with-cache-error', + url, + value, + }, + }); + }; + + const resolve = value => { + chrome.runtime.sendMessage({ + source: 'react-devtools-content-script', + payload: { + type: 'fetch-file-with-cache-complete', + url, + value, + }, + }); + }; + + fetch(url, {cache: 'force-cache'}).then( + response => { + if (response.ok) { + response + .text() + .then(text => resolve(text)) + .catch(error => reject(null)); + } else { + reject(null); + } + }, + error => reject(null), + ); + } + break; + case 'react-devtools-inject-backend': + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('build/react_devtools_backend.js'); + document.documentElement.appendChild(script); + script.parentNode.removeChild(script); + break; } }); @@ -45,18 +90,18 @@ window.addEventListener('message', function(evt) { // while navigating the history to a document that has not been destroyed yet, // replay the last detection result if the content script is active and the // document has been hidden and shown again. -window.addEventListener('pageshow', function(evt) { - if (!lastDetectionResult || evt.target !== window.document) { +window.addEventListener('pageshow', function({target}) { + if (!lastDetectionResult || target !== window.document) { return; } chrome.runtime.sendMessage(lastDetectionResult); }); const detectReact = ` -window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function(evt) { +window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function({reactBuildType}) { window.postMessage({ source: 'react-devtools-detector', - reactBuildType: evt.reactBuildType, + reactBuildType, }, '*'); }); `; diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 06c227d7e4cbe..43ef1a1b26864 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -25,6 +25,27 @@ const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY = const isChrome = getBrowserName() === 'Chrome'; +const cachedNetworkEvents = new Map(); + +// Cache JavaScript resources as the page loads them. +// This helps avoid unnecessary duplicate requests when hook names are parsed. +// Responses with a Vary: 'Origin' might not match future requests. +// This lets us avoid a possible (expensive) cache miss. +// For more info see: github.com/facebook/react/pull/22198 +chrome.devtools.network.onRequestFinished.addListener( + function onRequestFinished(event) { + if (event.request.method === 'GET') { + switch (event.response.content.mimeType) { + case 'application/javascript': + case 'application/x-javascript': + case 'text/javascript': + cachedNetworkEvents.set(event.request.url, event); + break; + } + } + }, +); + let panelCreated = false; // The renderer interface can't read saved component filters directly, @@ -212,20 +233,76 @@ function createPanelIfReactLoaded() { } }; + // For some reason in Firefox, chrome.runtime.sendMessage() from a content script + // never reaches the chrome.runtime.onMessage event listener. + let fetchFileWithCaching = null; + if (isChrome) { + // Fetching files from the extension won't make use of the network cache + // for resources that have already been loaded by the page. + // This helper function allows the extension to request files to be fetched + // by the content script (running in the page) to increase the likelihood of a cache hit. + fetchFileWithCaching = url => { + const event = cachedNetworkEvents.get(url); + if (event != null) { + // If this resource has already been cached locally, + // skip the network queue (which might not be a cache hit anyway) + // and just use the cached response. + return new Promise(resolve => { + event.getContent(content => resolve(content)); + }); + } + + // If DevTools was opened after the page started loading, + // we may have missed some requests. + // So fall back to a fetch() and hope we get a cached response. + + return new Promise((resolve, reject) => { + function onPortMessage({payload, source}) { + if (source === 'react-devtools-content-script') { + switch (payload?.type) { + case 'fetch-file-with-cache-complete': + chrome.runtime.onMessage.removeListener(onPortMessage); + resolve(payload.value); + break; + case 'fetch-file-with-cache-error': + chrome.runtime.onMessage.removeListener(onPortMessage); + reject(payload.value); + break; + } + } + } + + chrome.runtime.onMessage.addListener(onPortMessage); + + chrome.devtools.inspectedWindow.eval(` + window.postMessage({ + source: 'react-devtools-extension', + payload: { + type: 'fetch-file-with-cache', + url: "${url}", + }, + }); + `); + }); + }; + } + root = createRoot(document.createElement('div')); render = (overrideTab = mostRecentOverrideTab) => { mostRecentOverrideTab = overrideTab; import('./parseHookNames').then( - ({parseHookNames, purgeCachedMetadata}) => { + ({parseHookNames, prefetchSourceFiles, purgeCachedMetadata}) => { root.render( createElement(DevTools, { bridge, browserTheme: getBrowserTheme(), componentsPortalContainer, enabledInspectedElementContextMenu: true, + fetchFileWithCaching, loadHookNames: parseHookNames, overrideTab, + prefetchSourceFiles, profilerPortalContainer, purgeCachedHookNamesMetadata: purgeCachedMetadata, showTabBar: false, @@ -366,6 +443,9 @@ function createPanelIfReactLoaded() { // Re-initialize DevTools panel when a new page is loaded. chrome.devtools.network.onNavigated.addListener(function onNavigated() { + // Clear cached requests when a new page is opened. + cachedNetworkEvents.clear(); + // Re-initialize saved filters on navigation, // since global values stored on window get reset in this case. syncSavedPreferences(); @@ -382,6 +462,9 @@ function createPanelIfReactLoaded() { // Load (or reload) the DevTools extension when the user navigates to a new page. function checkPageForReact() { + // Clear cached requests when a new page is opened. + cachedNetworkEvents.clear(); + syncSavedPreferences(); createPanelIfReactLoaded(); } diff --git a/packages/react-devtools-extensions/src/parseHookNames/index.js b/packages/react-devtools-extensions/src/parseHookNames/index.js index 643655ae757e0..eae8440399c7b 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/index.js +++ b/packages/react-devtools-extensions/src/parseHookNames/index.js @@ -7,17 +7,59 @@ * @flow */ -// This file uses workerize to load ./parseHookNames.worker as a webworker and instanciates it, -// exposing flow typed functions that can be used on other files. +import type {HookSourceAndMetadata} from './loadSourceAndMetadata'; +import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; +import type {HookNames} from 'react-devtools-shared/src/types'; +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools'; -import WorkerizedParseHookNames from './parseHookNames.worker'; -import typeof * as ParseHookNamesModule from './parseHookNames'; +import {withAsyncPerformanceMark} from 'react-devtools-shared/src/PerformanceMarks'; +import WorkerizedParseSourceAndMetadata from './parseSourceAndMetadata.worker'; +import typeof * as ParseSourceAndMetadataModule from './parseSourceAndMetadata'; +import { + flattenHooksList, + loadSourceAndMetadata, + prefetchSourceFiles, +} from './loadSourceAndMetadata'; -const workerizedParseHookNames: ParseHookNamesModule = WorkerizedParseHookNames(); +const workerizedParseHookNames: ParseSourceAndMetadataModule = WorkerizedParseSourceAndMetadata(); -type ParseHookNames = $PropertyType; +export {prefetchSourceFiles}; -export const parseHookNames: ParseHookNames = hooksTree => - workerizedParseHookNames.parseHookNames(hooksTree); +export function parseSourceAndMetadata( + hooksList: Array, + locationKeyToHookSourceAndMetadata: Map, +): Promise { + return workerizedParseHookNames.parseSourceAndMetadata( + hooksList, + locationKeyToHookSourceAndMetadata, + ); +} export const purgeCachedMetadata = workerizedParseHookNames.purgeCachedMetadata; + +const EMPTY_MAP = new Map(); + +export async function parseHookNames( + hooksTree: HooksTree, + fetchFileWithCaching: FetchFileWithCaching | null, +): Promise { + return withAsyncPerformanceMark('parseHookNames', async () => { + const hooksList = flattenHooksList(hooksTree); + if (hooksList.length === 0) { + // This component tree contains no named hooks. + return EMPTY_MAP; + } + + // Runs on the main/UI thread so it can reuse Network cache: + const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata( + hooksList, + fetchFileWithCaching, + ); + + // Runs in a Worker because it's CPU intensive: + return parseSourceAndMetadata( + hooksList, + locationKeyToHookSourceAndMetadata, + ); + }); +} diff --git a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js new file mode 100644 index 0000000000000..dad3b864636b8 --- /dev/null +++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js @@ -0,0 +1,570 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Parsing source and source maps is done in a Web Worker +// because parsing is CPU intensive and should not block the UI thread. +// +// Fetching source and source map files is intentionally done on the UI thread +// so that loaded source files can reuse the browser's Network cache. +// Requests made from within an extension do not share the page's Network cache, +// but messages can be sent from the UI thread to the content script +// which can make a request from the page's context (with caching). +// +// Some overhead may be incurred sharing (serializing) the loaded data between contexts, +// but less than fetching the file to begin with, +// and in some cases we can avoid serializing the source code at all +// (e.g. when we are in an environment that supports our custom metadata format). +// +// The overall flow of this file is such: +// 1. Find the Set of source files defining the hooks and load them all. +// Then for each source file, do the following: +// +// a. Search loaded source file to see if a source map is available. +// If so, load that file and pass it to a Worker for parsing. +// The source map is used to retrieve the original source, +// which is then also parsed in the Worker to infer hook names. +// This is less ideal because parsing a full source map is slower, +// since we need to evaluate the mappings in order to map the runtime code to the original source, +// but at least the eventual source that we parse to an AST is small/fast. +// +// b. If no source map, pass the full source to a Worker for parsing. +// Use the source to infer hook names. +// This is the least optimal route as parsing the full source is very CPU intensive. +// +// In the future, we may add an additional optimization the above sequence. +// This check would come before the source map check: +// +// a. Search loaded source file to see if a custom React metadata file is available. +// If so, load that file and pass it to a Worker for parsing and extracting. +// This is the fastest option since our custom metadata file is much smaller than a full source map, +// and there is no need to convert runtime code to the original source. + +import LRU from 'lru-cache'; +import {__DEBUG__} from 'react-devtools-shared/src/constants'; +import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; +import {sourceMapIncludesSource} from '../SourceMapUtils'; +import { + withAsyncPerformanceMark, + withCallbackPerformanceMark, + withSyncPerformanceMark, +} from 'react-devtools-shared/src/PerformanceMarks'; + +import type {LRUCache} from 'react-devtools-shared/src/types'; +import type { + HooksNode, + HookSource, + HooksTree, +} from 'react-debug-tools/src/ReactDebugHooks'; +import type {MixedSourceMap} from 'react-devtools-extensions/src/SourceMapTypes'; +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools'; + +// Prefer a cached albeit stale response to reduce download time. +// We wouldn't want to load/parse a newer version of the source (even if one existed). +const FETCH_OPTIONS = {cache: 'force-cache'}; + +const MAX_SOURCE_LENGTH = 100_000_000; + +// Fetch requests originated from an extension might not have origin headers +// which may prevent subsequent requests from using cached responses +// if the server returns a Vary: 'Origin' header +// so this cache will temporarily store pre-fetches sources in memory. +const prefetchedSources: LRUCache = new LRU({ + max: 15, +}); + +export type HookSourceAndMetadata = {| + // Generated by react-debug-tools. + hookSource: HookSource, + + // Compiled code (React components or custom hooks) containing primitive hook calls. + runtimeSourceCode: string | null, + + // Same as hookSource.fileName but guaranteed to be non-null. + runtimeSourceURL: string, + + // Raw source map JSON. + // Either decoded from an inline source map or loaded from an externa source map file. + // Sources without source maps won't have this. + sourceMapJSON: MixedSourceMap | null, + + // External URL of source map. + // Sources without source maps (or with inline source maps) won't have this. + sourceMapURL: string | null, +|}; + +export type LocationKeyToHookSourceAndMetadata = Map< + string, + HookSourceAndMetadata, +>; +export type HooksList = Array; + +export async function loadSourceAndMetadata( + hooksList: HooksList, + fetchFileWithCaching: FetchFileWithCaching | null, +): Promise { + return withAsyncPerformanceMark('loadSourceAndMetadata()', async () => { + const locationKeyToHookSourceAndMetadata = withSyncPerformanceMark( + 'initializeHookSourceAndMetadata', + () => initializeHookSourceAndMetadata(hooksList), + ); + + await withAsyncPerformanceMark('loadSourceFiles()', () => + loadSourceFiles(locationKeyToHookSourceAndMetadata, fetchFileWithCaching), + ); + + await withAsyncPerformanceMark('extractAndLoadSourceMapJSON()', () => + extractAndLoadSourceMapJSON(locationKeyToHookSourceAndMetadata), + ); + + // At this point, we've loaded JS source (text) and source map (JSON). + // The remaining works (parsing these) is CPU intensive and should be done in a worker. + return locationKeyToHookSourceAndMetadata; + }); +} + +function decodeBase64String(encoded: string): Object { + if (typeof atob === 'function') { + return atob(encoded); + } else if ( + typeof Buffer !== 'undefined' && + Buffer !== null && + typeof Buffer.from === 'function' + ) { + return Buffer.from(encoded, 'base64'); + } else { + throw Error('Cannot decode base64 string'); + } +} + +function extractAndLoadSourceMapJSON( + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, +): Promise<*> { + // Deduplicate fetches, since there can be multiple location keys per source map. + const dedupedFetchPromises = new Map(); + + const setterPromises = []; + locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => { + const sourceMapRegex = / ?sourceMappingURL=([^\s'"]+)/gm; + const runtimeSourceCode = ((hookSourceAndMetadata.runtimeSourceCode: any): string); + + // TODO (named hooks) Search for our custom metadata first. + // If it's found, we should use it rather than source maps. + + // TODO (named hooks) If this RegExp search is slow, we could try breaking it up + // first using an indexOf(' sourceMappingURL=') to find the start of the comment + // (probably at the end of the file) and then running the RegExp on the remaining substring. + let sourceMappingURLMatch = withSyncPerformanceMark( + 'sourceMapRegex.exec(runtimeSourceCode)', + () => sourceMapRegex.exec(runtimeSourceCode), + ); + + if (sourceMappingURLMatch == null) { + if (__DEBUG__) { + console.log('extractAndLoadSourceMapJSON() No source map found'); + } + + // Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST(). + } else { + const externalSourceMapURLs = []; + while (sourceMappingURLMatch != null) { + const {runtimeSourceURL} = hookSourceAndMetadata; + const sourceMappingURL = sourceMappingURLMatch[1]; + const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0; + if (hasInlineSourceMap) { + // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch) + // since there can be multiple location keys per source map. + + // Web apps like Code Sandbox embed multiple inline source maps. + // In this case, we need to loop through and find the right one. + // We may also need to trim any part of this string that isn't based64 encoded data. + const trimmed = ((sourceMappingURL.match( + /base64,([a-zA-Z0-9+\/=]+)/, + ): any): Array)[1]; + const decoded = withSyncPerformanceMark('decodeBase64String()', () => + decodeBase64String(trimmed), + ); + + const sourceMapJSON = withSyncPerformanceMark( + 'JSON.parse(decoded)', + () => JSON.parse(decoded), + ); + + if (__DEBUG__) { + console.groupCollapsed( + 'extractAndLoadSourceMapJSON() Inline source map', + ); + console.log(sourceMapJSON); + console.groupEnd(); + } + + // Hook source might be a URL like "https://4syus.csb.app/src/App.js" + // Parsed source map might be a partial path like "src/App.js" + if (sourceMapIncludesSource(sourceMapJSON, runtimeSourceURL)) { + hookSourceAndMetadata.sourceMapJSON = sourceMapJSON; + + // OPTIMIZATION If we've located a source map for this source, + // we'll use it to retrieve the original source (to extract hook names). + // We only fall back to parsing the full source code is when there's no source map. + // The source is (potentially) very large, + // So we can avoid the overhead of serializing it unnecessarily. + hookSourceAndMetadata.runtimeSourceCode = null; + + break; + } + } else { + externalSourceMapURLs.push(sourceMappingURL); + } + + // If the first source map we found wasn't a match, check for more. + sourceMappingURLMatch = withSyncPerformanceMark( + 'sourceMapRegex.exec(runtimeSourceCode)', + () => sourceMapRegex.exec(runtimeSourceCode), + ); + } + + if (hookSourceAndMetadata.sourceMapJSON === null) { + externalSourceMapURLs.forEach((sourceMappingURL, index) => { + if (index !== externalSourceMapURLs.length - 1) { + // Files with external source maps should only have a single source map. + // More than one result might indicate an edge case, + // like a string in the source code that matched our "sourceMappingURL" regex. + // We should just skip over cases like this. + console.warn( + `More than one external source map detected in the source file; skipping "${sourceMappingURL}"`, + ); + return; + } + + const {runtimeSourceURL} = hookSourceAndMetadata; + let url = sourceMappingURL; + if (!url.startsWith('http') && !url.startsWith('/')) { + // Resolve paths relative to the location of the file name + const lastSlashIdx = runtimeSourceURL.lastIndexOf('/'); + if (lastSlashIdx !== -1) { + const baseURL = runtimeSourceURL.slice( + 0, + runtimeSourceURL.lastIndexOf('/'), + ); + url = `${baseURL}/${url}`; + } + } + + hookSourceAndMetadata.sourceMapURL = url; + + const fetchPromise = + dedupedFetchPromises.get(url) || + fetchFile(url).then( + sourceMapContents => { + const sourceMapJSON = withSyncPerformanceMark( + 'JSON.parse(sourceMapContents)', + () => JSON.parse(sourceMapContents), + ); + + return sourceMapJSON; + }, + + // In this case, we fall back to the assumption that the source has no source map. + // This might indicate an (unlikely) edge case that had no source map, + // but contained the string "sourceMappingURL". + error => null, + ); + + if (__DEBUG__) { + if (!dedupedFetchPromises.has(url)) { + console.log( + `extractAndLoadSourceMapJSON() External source map "${url}"`, + ); + } + } + + dedupedFetchPromises.set(url, fetchPromise); + + setterPromises.push( + fetchPromise.then(sourceMapJSON => { + if (sourceMapJSON !== null) { + hookSourceAndMetadata.sourceMapJSON = sourceMapJSON; + + // OPTIMIZATION If we've located a source map for this source, + // we'll use it to retrieve the original source (to extract hook names). + // We only fall back to parsing the full source code is when there's no source map. + // The source is (potentially) very large, + // So we can avoid the overhead of serializing it unnecessarily. + hookSourceAndMetadata.runtimeSourceCode = null; + } + }), + ); + }); + } + } + }); + + return Promise.all(setterPromises); +} + +function fetchFile( + url: string, + markName?: string = 'fetchFile', +): Promise { + return withCallbackPerformanceMark(`${markName}("${url}")`, done => { + return new Promise((resolve, reject) => { + fetch(url, FETCH_OPTIONS).then( + response => { + if (response.ok) { + response + .text() + .then(text => { + done(); + resolve(text); + }) + .catch(error => { + if (__DEBUG__) { + console.log( + `${markName}() Could not read text for url "${url}"`, + ); + } + done(); + reject(null); + }); + } else { + if (__DEBUG__) { + console.log(`${markName}() Got bad response for url "${url}"`); + } + done(); + reject(null); + } + }, + error => { + if (__DEBUG__) { + console.log(`${markName}() Could not fetch file: ${error.message}`); + } + done(); + reject(null); + }, + ); + }); + }); +} + +export function hasNamedHooks(hooksTree: HooksTree): boolean { + for (let i = 0; i < hooksTree.length; i++) { + const hook = hooksTree[i]; + + if (!isUnnamedBuiltInHook(hook)) { + return true; + } + + if (hook.subHooks.length > 0) { + if (hasNamedHooks(hook.subHooks)) { + return true; + } + } + } + + return false; +} + +export function flattenHooksList(hooksTree: HooksTree): HooksList { + const hooksList: HooksList = []; + withSyncPerformanceMark('flattenHooksList()', () => { + flattenHooksListImpl(hooksTree, hooksList); + }); + + if (__DEBUG__) { + console.log('flattenHooksList() hooksList:', hooksList); + } + + return hooksList; +} + +function flattenHooksListImpl( + hooksTree: HooksTree, + hooksList: Array, +): void { + for (let i = 0; i < hooksTree.length; i++) { + const hook = hooksTree[i]; + + if (isUnnamedBuiltInHook(hook)) { + // No need to load source code or do any parsing for unnamed hooks. + if (__DEBUG__) { + console.log('flattenHooksListImpl() Skipping unnamed hook', hook); + } + + continue; + } + + hooksList.push(hook); + + if (hook.subHooks.length > 0) { + flattenHooksListImpl(hook.subHooks, hooksList); + } + } +} + +function initializeHookSourceAndMetadata( + hooksList: Array, +): LocationKeyToHookSourceAndMetadata { + // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks. + const locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata = new Map(); + for (let i = 0; i < hooksList.length; i++) { + const hook = hooksList[i]; + + const hookSource = hook.hookSource; + if (hookSource == null) { + // Older versions of react-debug-tools don't include this information. + // In this case, we can't continue. + throw Error('Hook source code location not found.'); + } + + const locationKey = getHookSourceLocationKey(hookSource); + if (!locationKeyToHookSourceAndMetadata.has(locationKey)) { + // Can't be null because getHookSourceLocationKey() would have thrown + const runtimeSourceURL = ((hookSource.fileName: any): string); + + const hookSourceAndMetadata: HookSourceAndMetadata = { + hookSource, + runtimeSourceCode: null, + runtimeSourceURL, + sourceMapJSON: null, + sourceMapURL: null, + }; + + locationKeyToHookSourceAndMetadata.set( + locationKey, + hookSourceAndMetadata, + ); + } + } + + return locationKeyToHookSourceAndMetadata; +} + +// Determines whether incoming hook is a primitive hook that gets assigned to variables. +function isUnnamedBuiltInHook(hook: HooksNode) { + return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes( + hook.name, + ); +} + +function loadSourceFiles( + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, + fetchFileWithCaching: FetchFileWithCaching | null, +): Promise<*> { + // Deduplicate fetches, since there can be multiple location keys per file. + const dedupedFetchPromises = new Map(); + + const setterPromises = []; + locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => { + const {runtimeSourceURL} = hookSourceAndMetadata; + + const prefetchedSourceCode = prefetchedSources.get(runtimeSourceURL); + if (prefetchedSourceCode != null) { + hookSourceAndMetadata.runtimeSourceCode = prefetchedSourceCode; + } else { + let fetchFileFunction = fetchFile; + if (fetchFileWithCaching != null) { + // If a helper function has been injected to fetch with caching, + // use it to fetch the (already loaded) source file. + fetchFileFunction = url => { + return withAsyncPerformanceMark( + `fetchFileWithCaching("${url}")`, + () => { + return ((fetchFileWithCaching: any): FetchFileWithCaching)(url); + }, + ); + }; + } + + const fetchPromise = + dedupedFetchPromises.get(runtimeSourceURL) || + fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => { + // TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps, + // because then we need to parse the full source file as an AST. + if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) { + throw Error('Source code too large to parse'); + } + + if (__DEBUG__) { + console.groupCollapsed( + `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, + ); + console.log(runtimeSourceCode); + console.groupEnd(); + } + + return runtimeSourceCode; + }); + dedupedFetchPromises.set(runtimeSourceURL, fetchPromise); + + setterPromises.push( + fetchPromise.then(runtimeSourceCode => { + hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode; + }), + ); + } + }); + + return Promise.all(setterPromises); +} + +export function prefetchSourceFiles( + hooksTree: HooksTree, + fetchFileWithCaching: FetchFileWithCaching | null, +): void { + // Deduplicate fetches, since there can be multiple location keys per source map. + const dedupedFetchPromises = new Set(); + + let fetchFileFunction = null; + if (fetchFileWithCaching != null) { + // If a helper function has been injected to fetch with caching, + // use it to fetch the (already loaded) source file. + fetchFileFunction = url => { + return withAsyncPerformanceMark( + `[pre] fetchFileWithCaching("${url}")`, + () => { + return ((fetchFileWithCaching: any): FetchFileWithCaching)(url); + }, + ); + }; + } else { + fetchFileFunction = url => fetchFile(url, '[pre] fetchFile'); + } + + const hooksQueue = Array.from(hooksTree); + + for (let i = 0; i < hooksQueue.length; i++) { + const hook = hooksQueue.pop(); + if (isUnnamedBuiltInHook(hook)) { + continue; + } + + const hookSource = hook.hookSource; + if (hookSource == null) { + continue; + } + + const runtimeSourceURL = ((hookSource.fileName: any): string); + + if (prefetchedSources.has(runtimeSourceURL)) { + // If we've already fetched this source, skip it. + continue; + } + + if (!dedupedFetchPromises.has(runtimeSourceURL)) { + dedupedFetchPromises.add(runtimeSourceURL); + + fetchFileFunction(runtimeSourceURL).then(text => { + prefetchedSources.set(runtimeSourceURL, text); + }); + } + + if (hook.subHooks.length > 0) { + hooksQueue.push(...hook.subHooks); + } + } +} diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js b/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js deleted file mode 100644 index 9afd3dea034a5..0000000000000 --- a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.js +++ /dev/null @@ -1,750 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import {parse} from '@babel/parser'; -import LRU from 'lru-cache'; -import {SourceMapConsumer} from 'source-map-js'; -import {getHookName} from '../astUtils'; -import {areSourceMapsAppliedToErrors} from '../ErrorTester'; -import {__DEBUG__} from 'react-devtools-shared/src/constants'; -import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; -import {sourceMapIncludesSource} from '../SourceMapUtils'; -import {SourceMapMetadataConsumer} from '../SourceMapMetadataConsumer'; -import { - withAsyncPerformanceMark, - withCallbackPerformanceMark, - withSyncPerformanceMark, -} from 'react-devtools-shared/src/PerformanceMarks'; - -import type { - HooksNode, - HookSource, - HooksTree, -} from 'react-debug-tools/src/ReactDebugHooks'; -import type {HookNames, LRUCache} from 'react-devtools-shared/src/types'; -import type {SourceConsumer} from '../astUtils'; - -const MAX_SOURCE_LENGTH = 100_000_000; - -type AST = mixed; - -type HookSourceData = {| - // Generated by react-debug-tools. - hookSource: HookSource, - - // API for consuming metadfata present in extended source map. - metadataConsumer: SourceMapMetadataConsumer | null, - - // AST for original source code; typically comes from a consumed source map. - originalSourceAST: AST | null, - - // Source code (React components or custom hooks) containing primitive hook calls. - // If no source map has been provided, this code will be the same as runtimeSourceCode. - originalSourceCode: string | null, - - // Original source URL if there is a source map, or the same as runtimeSourceURL. - originalSourceURL: string | null, - - // Compiled code (React components or custom hooks) containing primitive hook calls. - runtimeSourceCode: string | null, - - // Same as hookSource.fileName but guaranteed to be non-null. - runtimeSourceURL: string, - - // APIs from source-map for parsing source maps (if detected). - sourceConsumer: SourceConsumer | null, - - // External URL of source map. - // Sources without source maps (or with inline source maps) won't have this. - sourceMapURL: string | null, -|}; - -type CachedRuntimeCodeMetadata = {| - sourceConsumer: SourceConsumer | null, - metadataConsumer: SourceMapMetadataConsumer | null, -|}; - -const runtimeURLToMetadataCache: LRUCache< - string, - CachedRuntimeCodeMetadata, -> = new LRU({ - max: 50, - dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => { - if (__DEBUG__) { - console.log( - `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`, - ); - } - - const sourceConsumer = metadata.sourceConsumer; - if (sourceConsumer !== null) { - sourceConsumer.destroy(); - } - }, -}); - -type CachedSourceCodeMetadata = {| - originalSourceAST: AST, - originalSourceCode: string, -|}; - -const originalURLToMetadataCache: LRUCache< - string, - CachedSourceCodeMetadata, -> = new LRU({ - max: 50, - dispose: (originalSourceURL: string, metadata: CachedSourceCodeMetadata) => { - if (__DEBUG__) { - console.log( - `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`, - ); - } - }, -}); - -export async function parseHookNames( - hooksTree: HooksTree, -): Promise { - const hooksList: Array = []; - withSyncPerformanceMark('flattenHooksList()', () => { - flattenHooksList(hooksTree, hooksList); - }); - - return withAsyncPerformanceMark('parseHookNames()', () => - parseHookNamesImpl(hooksList), - ); -} - -async function parseHookNamesImpl( - hooksList: HooksNode[], -): Promise { - if (__DEBUG__) { - console.log('parseHookNames() hooksList:', hooksList); - } - - // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks. - const locationKeyToHookSourceData: Map = new Map(); - for (let i = 0; i < hooksList.length; i++) { - const hook = hooksList[i]; - - const hookSource = hook.hookSource; - if (hookSource == null) { - // Older versions of react-debug-tools don't include this information. - // In this case, we can't continue. - throw Error('Hook source code location not found.'); - } - - const locationKey = getHookSourceLocationKey(hookSource); - if (!locationKeyToHookSourceData.has(locationKey)) { - // Can't be null because getHookSourceLocationKey() would have thrown - const runtimeSourceURL = ((hookSource.fileName: any): string); - - const hookSourceData: HookSourceData = { - hookSource, - metadataConsumer: null, - originalSourceAST: null, - originalSourceCode: null, - originalSourceURL: null, - runtimeSourceCode: null, - runtimeSourceURL, - sourceConsumer: null, - sourceMapURL: null, - }; - - // If we've already loaded the source map info for this file, - // we can skip reloading it (and more importantly, re-parsing it). - const runtimeMetadata = runtimeURLToMetadataCache.get( - hookSourceData.runtimeSourceURL, - ); - if (runtimeMetadata != null) { - if (__DEBUG__) { - console.groupCollapsed( - `parseHookNames() Found cached runtime metadata for file "${hookSourceData.runtimeSourceURL}"`, - ); - console.log(runtimeMetadata); - console.groupEnd(); - } - hookSourceData.sourceConsumer = runtimeMetadata.sourceConsumer; - hookSourceData.metadataConsumer = runtimeMetadata.metadataConsumer; - } - - locationKeyToHookSourceData.set(locationKey, hookSourceData); - } - } - - await withAsyncPerformanceMark('loadSourceFiles()', () => - loadSourceFiles(locationKeyToHookSourceData), - ); - - await withAsyncPerformanceMark('extractAndLoadSourceMaps()', () => - extractAndLoadSourceMaps(locationKeyToHookSourceData), - ); - - withSyncPerformanceMark('parseSourceAST()', () => - parseSourceAST(locationKeyToHookSourceData), - ); - - withSyncPerformanceMark('updateLruCache()', () => - updateLruCache(locationKeyToHookSourceData), - ); - - return withSyncPerformanceMark('findHookNames()', () => - findHookNames(hooksList, locationKeyToHookSourceData), - ); -} - -function decodeBase64String(encoded: string): Object { - if (typeof atob === 'function') { - return atob(encoded); - } else if ( - typeof Buffer !== 'undefined' && - Buffer !== null && - typeof Buffer.from === 'function' - ) { - return Buffer.from(encoded, 'base64'); - } else { - throw Error('Cannot decode base64 string'); - } -} - -function extractAndLoadSourceMaps( - locationKeyToHookSourceData: Map, -): Promise<*> { - // SourceMapConsumer.initialize() does nothing when running in Node (aka Jest) - // because the wasm file is automatically read from the file system - // so we can avoid triggering a warning message about this. - if (!__TEST__) { - if (__DEBUG__) { - console.log( - 'extractAndLoadSourceMaps() Initializing source-map library ...', - ); - } - } - - // Deduplicate fetches, since there can be multiple location keys per source map. - const fetchPromises = new Map(); - - const setPromises = []; - locationKeyToHookSourceData.forEach(hookSourceData => { - if ( - hookSourceData.sourceConsumer != null && - hookSourceData.metadataConsumer != null - ) { - // Use cached source map and metadata consumers. - return; - } - - const sourceMapRegex = / ?sourceMappingURL=([^\s'"]+)/gm; - const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string); - - let sourceMappingURLMatch = withSyncPerformanceMark( - 'sourceMapRegex.exec(runtimeSourceCode)', - () => sourceMapRegex.exec(runtimeSourceCode), - ); - - if (sourceMappingURLMatch == null) { - // Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST(). - - if (__DEBUG__) { - console.log('extractAndLoadSourceMaps() No source map found'); - } - } else { - const externalSourceMapURLs = []; - while (sourceMappingURLMatch != null) { - const {runtimeSourceURL} = hookSourceData; - const sourceMappingURL = sourceMappingURLMatch[1]; - const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0; - if (hasInlineSourceMap) { - // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch) - // since there can be multiple location keys per source map. - - // Web apps like Code Sandbox embed multiple inline source maps. - // In this case, we need to loop through and find the right one. - // We may also need to trim any part of this string that isn't based64 encoded data. - const trimmed = ((sourceMappingURL.match( - /base64,([a-zA-Z0-9+\/=]+)/, - ): any): Array)[1]; - const decoded = withSyncPerformanceMark('decodeBase64String()', () => - decodeBase64String(trimmed), - ); - - const parsed = withSyncPerformanceMark('JSON.parse(decoded)', () => - JSON.parse(decoded), - ); - - if (__DEBUG__) { - console.groupCollapsed( - 'extractAndLoadSourceMaps() Inline source map', - ); - console.log(parsed); - console.groupEnd(); - } - - // Hook source might be a URL like "https://4syus.csb.app/src/App.js" - // Parsed source map might be a partial path like "src/App.js" - if (sourceMapIncludesSource(parsed, runtimeSourceURL)) { - hookSourceData.metadataConsumer = withSyncPerformanceMark( - 'new SourceMapMetadataConsumer(parsed)', - () => new SourceMapMetadataConsumer(parsed), - ); - hookSourceData.sourceConsumer = withSyncPerformanceMark( - 'new SourceMapConsumer(parsed)', - () => new SourceMapConsumer(parsed), - ); - break; - } - } else { - externalSourceMapURLs.push(sourceMappingURL); - } - - sourceMappingURLMatch = withSyncPerformanceMark( - 'sourceMapRegex.exec(runtimeSourceCode)', - () => sourceMapRegex.exec(runtimeSourceCode), - ); - } - - const foundInlineSourceMap = - hookSourceData.sourceConsumer != null && - hookSourceData.metadataConsumer != null; - if (!foundInlineSourceMap) { - externalSourceMapURLs.forEach((sourceMappingURL, index) => { - if (index !== externalSourceMapURLs.length - 1) { - // Files with external source maps should only have a single source map. - // More than one result might indicate an edge case, - // like a string in the source code that matched our "sourceMappingURL" regex. - // We should just skip over cases like this. - console.warn( - `More than one external source map detected in the source file; skipping "${sourceMappingURL}"`, - ); - return; - } - - const {runtimeSourceURL} = hookSourceData; - let url = sourceMappingURL; - if (!url.startsWith('http') && !url.startsWith('/')) { - // Resolve paths relative to the location of the file name - const lastSlashIdx = runtimeSourceURL.lastIndexOf('/'); - if (lastSlashIdx !== -1) { - const baseURL = runtimeSourceURL.slice( - 0, - runtimeSourceURL.lastIndexOf('/'), - ); - url = `${baseURL}/${url}`; - } - } - - hookSourceData.sourceMapURL = url; - - const fetchPromise = - fetchPromises.get(url) || - fetchFile(url).then( - sourceMapContents => { - const parsed = withSyncPerformanceMark( - 'JSON.parse(sourceMapContents)', - () => JSON.parse(sourceMapContents), - ); - - const sourceConsumer = withSyncPerformanceMark( - 'new SourceMapConsumer(parsed)', - () => new SourceMapConsumer(parsed), - ); - - const metadataConsumer = withSyncPerformanceMark( - 'new SourceMapMetadataConsumer(parsed)', - () => new SourceMapMetadataConsumer(parsed), - ); - - return {sourceConsumer, metadataConsumer}; - }, - // In this case, we fall back to the assumption that the source has no source map. - // This might indicate an (unlikely) edge case that had no source map, - // but contained the string "sourceMappingURL". - error => null, - ); - - if (__DEBUG__) { - if (!fetchPromises.has(url)) { - console.log( - `extractAndLoadSourceMaps() External source map "${url}"`, - ); - } - } - - fetchPromises.set(url, fetchPromise); - setPromises.push( - fetchPromise.then(result => { - hookSourceData.metadataConsumer = - result?.metadataConsumer ?? null; - hookSourceData.sourceConsumer = result?.sourceConsumer ?? null; - }), - ); - }); - } - } - }); - return Promise.all(setPromises); -} - -function fetchFile(url: string): Promise { - return withCallbackPerformanceMark('fetchFile("' + url + '")', done => { - return new Promise((resolve, reject) => { - fetch(url).then( - response => { - if (response.ok) { - response - .text() - .then(text => { - done(); - resolve(text); - }) - .catch(error => { - if (__DEBUG__) { - console.log( - `fetchFile() Could not read text for url "${url}"`, - ); - } - done(); - reject(null); - }); - } else { - if (__DEBUG__) { - console.log(`fetchFile() Got bad response for url "${url}"`); - } - done(); - reject(null); - } - }, - error => { - if (__DEBUG__) { - console.log(`fetchFile() Could not fetch file: ${error.message}`); - } - done(); - reject(null); - }, - ); - }); - }); -} - -function findHookNames( - hooksList: Array, - locationKeyToHookSourceData: Map, -): HookNames { - const map: HookNames = new Map(); - - hooksList.map(hook => { - // We already guard against a null HookSource in parseHookNames() - const hookSource = ((hook.hookSource: any): HookSource); - const fileName = hookSource.fileName; - if (!fileName) { - return null; // Should not be reachable. - } - - const locationKey = getHookSourceLocationKey(hookSource); - const hookSourceData = locationKeyToHookSourceData.get(locationKey); - if (!hookSourceData) { - return null; // Should not be reachable. - } - - const {lineNumber, columnNumber} = hookSource; - if (!lineNumber || !columnNumber) { - return null; // Should not be reachable. - } - - const {originalSourceURL, sourceConsumer} = hookSourceData; - - let originalSourceColumnNumber; - let originalSourceLineNumber; - if (areSourceMapsAppliedToErrors() || !sourceConsumer) { - // Either the current environment automatically applies source maps to errors, - // or the current code had no source map to begin with. - // Either way, we don't need to convert the Error stack frame locations. - originalSourceColumnNumber = columnNumber; - originalSourceLineNumber = lineNumber; - } else { - const position = withSyncPerformanceMark( - 'sourceConsumer.originalPositionFor()', - () => - sourceConsumer.originalPositionFor({ - line: lineNumber, - - // Column numbers are represented differently between tools/engines. - // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based. - // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991 - column: columnNumber - 1, - }), - ); - - originalSourceColumnNumber = position.column; - originalSourceLineNumber = position.line; - } - - if (__DEBUG__) { - console.log( - `findHookNames() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`, - ); - } - - if ( - originalSourceLineNumber == null || - originalSourceColumnNumber == null || - originalSourceURL == null - ) { - return null; - } - - let name; - const {metadataConsumer} = hookSourceData; - if (metadataConsumer != null) { - name = withSyncPerformanceMark('metadataConsumer.hookNameFor()', () => - metadataConsumer.hookNameFor({ - line: originalSourceLineNumber, - column: originalSourceColumnNumber, - source: originalSourceURL, - }), - ); - } - - if (name == null) { - name = withSyncPerformanceMark('getHookName()', () => - getHookName( - hook, - hookSourceData.originalSourceAST, - ((hookSourceData.originalSourceCode: any): string), - ((originalSourceLineNumber: any): number), - originalSourceColumnNumber, - ), - ); - } - - if (__DEBUG__) { - console.log(`findHookNames() Found name "${name || '-'}"`); - } - - const key = getHookSourceLocationKey(hookSource); - map.set(key, name); - }); - - return map; -} - -function loadSourceFiles( - locationKeyToHookSourceData: Map, -): Promise<*> { - // Deduplicate fetches, since there can be multiple location keys per file. - const fetchPromises = new Map(); - - const setPromises = []; - locationKeyToHookSourceData.forEach(hookSourceData => { - const {runtimeSourceURL} = hookSourceData; - const fetchPromise = - fetchPromises.get(runtimeSourceURL) || - fetchFile(runtimeSourceURL).then(runtimeSourceCode => { - if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) { - throw Error('Source code too large to parse'); - } - if (__DEBUG__) { - console.groupCollapsed( - `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, - ); - console.log(runtimeSourceCode); - console.groupEnd(); - } - return runtimeSourceCode; - }); - fetchPromises.set(runtimeSourceURL, fetchPromise); - setPromises.push( - fetchPromise.then(runtimeSourceCode => { - hookSourceData.runtimeSourceCode = runtimeSourceCode; - }), - ); - }); - return Promise.all(setPromises); -} - -function parseSourceAST( - locationKeyToHookSourceData: Map, -): void { - locationKeyToHookSourceData.forEach(hookSourceData => { - if (hookSourceData.originalSourceAST !== null) { - // Use cached metadata. - return; - } - - const {metadataConsumer, sourceConsumer} = hookSourceData; - const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string); - let hasHookMap = false; - let originalSourceURL; - let originalSourceCode; - if (sourceConsumer !== null) { - // Parse and extract the AST from the source map. - const {lineNumber, columnNumber} = hookSourceData.hookSource; - if (lineNumber == null || columnNumber == null) { - throw Error('Hook source code location not found.'); - } - // Now that the source map has been loaded, - // extract the original source for later. - const {source} = withSyncPerformanceMark( - 'sourceConsumer.originalPositionFor()', - () => - sourceConsumer.originalPositionFor({ - line: lineNumber, - - // Column numbers are represented differently between tools/engines. - // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based. - // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991 - column: columnNumber - 1, - }), - ); - - if (source == null) { - // TODO (named hooks) maybe fall back to the runtime source instead of throwing? - throw new Error( - 'Could not map hook runtime location to original source location', - ); - } - - // TODO (named hooks) maybe canonicalize this URL somehow? - // It can be relative if the source map specifies it that way, - // but we use it as a cache key across different source maps and there can be collisions. - originalSourceURL = (source: string); - originalSourceCode = withSyncPerformanceMark( - 'sourceConsumer.sourceContentFor()', - () => (sourceConsumer.sourceContentFor(source, true): string), - ); - - if (__DEBUG__) { - console.groupCollapsed( - 'parseSourceAST() Extracted source code from source map', - ); - console.log(originalSourceCode); - console.groupEnd(); - } - - if ( - metadataConsumer != null && - metadataConsumer.hasHookMap(originalSourceURL) - ) { - hasHookMap = true; - } - } else { - // There's no source map to parse here so we can just parse the original source itself. - originalSourceCode = runtimeSourceCode; - // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space. - // Namespace them? - originalSourceURL = hookSourceData.runtimeSourceURL; - } - - hookSourceData.originalSourceCode = originalSourceCode; - hookSourceData.originalSourceURL = originalSourceURL; - - if (hasHookMap) { - // If there's a hook map present from an extended sourcemap then - // we don't need to parse the source files and instead can use the - // hook map to extract hook names. - return; - } - - // The cache also serves to deduplicate parsing by URL in our loop over - // location keys. This may need to change if we switch to async parsing. - const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL); - if (sourceMetadata != null) { - if (__DEBUG__) { - console.groupCollapsed( - `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`, - ); - console.log(sourceMetadata); - console.groupEnd(); - } - hookSourceData.originalSourceAST = sourceMetadata.originalSourceAST; - hookSourceData.originalSourceCode = sourceMetadata.originalSourceCode; - } else { - // TypeScript is the most commonly used typed JS variant so let's default to it - // unless we detect explicit Flow usage via the "@flow" pragma. - const plugin = - originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript'; - - // TODO (named hooks) Parsing should ideally be done off of the main thread. - const originalSourceAST = withSyncPerformanceMark( - '[@babel/parser] parse(originalSourceCode)', - () => - parse(originalSourceCode, { - sourceType: 'unambiguous', - plugins: ['jsx', plugin], - }), - ); - hookSourceData.originalSourceAST = originalSourceAST; - if (__DEBUG__) { - console.log( - `parseSourceAST() Caching source metadata for "${originalSourceURL}"`, - ); - } - originalURLToMetadataCache.set(originalSourceURL, { - originalSourceAST, - originalSourceCode, - }); - } - }); -} - -function flattenHooksList( - hooksTree: HooksTree, - hooksList: Array, -): void { - for (let i = 0; i < hooksTree.length; i++) { - const hook = hooksTree[i]; - - if (isUnnamedBuiltInHook(hook)) { - // No need to load source code or do any parsing for unnamed hooks. - if (__DEBUG__) { - console.log('flattenHooksList() Skipping unnamed hook', hook); - } - continue; - } - - hooksList.push(hook); - if (hook.subHooks.length > 0) { - flattenHooksList(hook.subHooks, hooksList); - } - } -} - -// Determines whether incoming hook is a primitive hook that gets assigned to variables. -function isUnnamedBuiltInHook(hook: HooksNode) { - return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes( - hook.name, - ); -} - -function updateLruCache( - locationKeyToHookSourceData: Map, -): void { - locationKeyToHookSourceData.forEach( - ({metadataConsumer, sourceConsumer, runtimeSourceURL}) => { - // Only set once to avoid triggering eviction/cleanup code. - if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) { - if (__DEBUG__) { - console.log( - `updateLruCache() Caching runtime metadata for "${runtimeSourceURL}"`, - ); - } - - runtimeURLToMetadataCache.set(runtimeSourceURL, { - metadataConsumer, - sourceConsumer, - }); - } - }, - ); -} - -export function purgeCachedMetadata(): void { - originalURLToMetadataCache.reset(); - runtimeURLToMetadataCache.reset(); -} diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js b/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js deleted file mode 100644 index c89f11a55db28..0000000000000 --- a/packages/react-devtools-extensions/src/parseHookNames/parseHookNames.worker.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import * as parseHookNamesModule from './parseHookNames'; - -export const parseHookNames = parseHookNamesModule.parseHookNames; -export const purgeCachedMetadata = parseHookNamesModule.purgeCachedMetadata; diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js new file mode 100644 index 0000000000000..143498c7e54c2 --- /dev/null +++ b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.js @@ -0,0 +1,458 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// For an overview of why the code in this file is structured this way, +// refer to header comments in loadSourceAndMetadata. + +import {parse} from '@babel/parser'; +import LRU from 'lru-cache'; +import {SourceMapConsumer} from 'source-map-js'; +import {getHookName} from '../astUtils'; +import {areSourceMapsAppliedToErrors} from '../ErrorTester'; +import {__DEBUG__} from 'react-devtools-shared/src/constants'; +import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; +import {SourceMapMetadataConsumer} from '../SourceMapMetadataConsumer'; +import { + withAsyncPerformanceMark, + withSyncPerformanceMark, +} from 'react-devtools-shared/src/PerformanceMarks'; + +import type { + HooksList, + LocationKeyToHookSourceAndMetadata, +} from './loadSourceAndMetadata'; +import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks'; +import type {HookNames, LRUCache} from 'react-devtools-shared/src/types'; +import type {SourceConsumer} from '../astUtils'; + +type AST = mixed; + +type HookParsedMetadata = {| + // API for consuming metadfata present in extended source map. + metadataConsumer: SourceMapMetadataConsumer | null, + + // AST for original source code; typically comes from a consumed source map. + originalSourceAST: AST | null, + + // Source code (React components or custom hooks) containing primitive hook calls. + // If no source map has been provided, this code will be the same as runtimeSourceCode. + originalSourceCode: string | null, + + // Original source URL if there is a source map, or the same as runtimeSourceURL. + originalSourceURL: string | null, + + // APIs from source-map for parsing source maps (if detected). + sourceConsumer: SourceConsumer | null, +|}; + +type LocationKeyToHookParsedMetadata = Map; + +type CachedRuntimeCodeMetadata = {| + sourceConsumer: SourceConsumer | null, + metadataConsumer: SourceMapMetadataConsumer | null, +|}; + +const runtimeURLToMetadataCache: LRUCache< + string, + CachedRuntimeCodeMetadata, +> = new LRU({ + max: 50, + dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => { + if (__DEBUG__) { + console.log( + `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`, + ); + } + + const sourceConsumer = metadata.sourceConsumer; + if (sourceConsumer !== null) { + sourceConsumer.destroy(); + } + }, +}); + +type CachedSourceCodeMetadata = {| + originalSourceAST: AST, + originalSourceCode: string, +|}; + +const originalURLToMetadataCache: LRUCache< + string, + CachedSourceCodeMetadata, +> = new LRU({ + max: 50, + dispose: (originalSourceURL: string, metadata: CachedSourceCodeMetadata) => { + if (__DEBUG__) { + console.log( + `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`, + ); + } + }, +}); + +export async function parseSourceAndMetadata( + hooksList: HooksList, + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, +): Promise { + return withAsyncPerformanceMark('parseSourceAndMetadata()', async () => { + const locationKeyToHookParsedMetadata = withSyncPerformanceMark( + 'initializeHookParsedMetadata', + () => initializeHookParsedMetadata(locationKeyToHookSourceAndMetadata), + ); + + withSyncPerformanceMark('parseSourceMaps', () => + parseSourceMaps( + locationKeyToHookSourceAndMetadata, + locationKeyToHookParsedMetadata, + ), + ); + + withSyncPerformanceMark('parseSourceAST()', () => + parseSourceAST( + locationKeyToHookSourceAndMetadata, + locationKeyToHookParsedMetadata, + ), + ); + + return withSyncPerformanceMark('findHookNames()', () => + findHookNames(hooksList, locationKeyToHookParsedMetadata), + ); + }); +} + +function findHookNames( + hooksList: HooksList, + locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata, +): HookNames { + const map: HookNames = new Map(); + + hooksList.map(hook => { + // We already guard against a null HookSource in parseHookNames() + const hookSource = ((hook.hookSource: any): HookSource); + const fileName = hookSource.fileName; + if (!fileName) { + return null; // Should not be reachable. + } + + const locationKey = getHookSourceLocationKey(hookSource); + const hookParsedMetadata = locationKeyToHookParsedMetadata.get(locationKey); + if (!hookParsedMetadata) { + return null; // Should not be reachable. + } + + const {lineNumber, columnNumber} = hookSource; + if (!lineNumber || !columnNumber) { + return null; // Should not be reachable. + } + + const {originalSourceURL, sourceConsumer} = hookParsedMetadata; + + let originalSourceColumnNumber; + let originalSourceLineNumber; + if (areSourceMapsAppliedToErrors() || !sourceConsumer) { + // Either the current environment automatically applies source maps to errors, + // or the current code had no source map to begin with. + // Either way, we don't need to convert the Error stack frame locations. + originalSourceColumnNumber = columnNumber; + originalSourceLineNumber = lineNumber; + } else { + // TODO (named hooks) Refactor this read, github.com/facebook/react/pull/22181 + const position = withSyncPerformanceMark( + 'sourceConsumer.originalPositionFor()', + () => + sourceConsumer.originalPositionFor({ + line: lineNumber, + + // Column numbers are represented differently between tools/engines. + // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based. + // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991 + column: columnNumber - 1, + }), + ); + + originalSourceColumnNumber = position.column; + originalSourceLineNumber = position.line; + } + + if (__DEBUG__) { + console.log( + `findHookNames() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`, + ); + } + + if ( + originalSourceLineNumber == null || + originalSourceColumnNumber == null || + originalSourceURL == null + ) { + return null; + } + + let name; + const {metadataConsumer} = hookParsedMetadata; + if (metadataConsumer != null) { + name = withSyncPerformanceMark('metadataConsumer.hookNameFor()', () => + metadataConsumer.hookNameFor({ + line: originalSourceLineNumber, + column: originalSourceColumnNumber, + source: originalSourceURL, + }), + ); + } + + if (name == null) { + name = withSyncPerformanceMark('getHookName()', () => + getHookName( + hook, + hookParsedMetadata.originalSourceAST, + ((hookParsedMetadata.originalSourceCode: any): string), + ((originalSourceLineNumber: any): number), + originalSourceColumnNumber, + ), + ); + } + + if (__DEBUG__) { + console.log(`findHookNames() Found name "${name || '-'}"`); + } + + const key = getHookSourceLocationKey(hookSource); + map.set(key, name); + }); + + return map; +} + +function initializeHookParsedMetadata( + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, +) { + // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks. + const locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata = new Map(); + locationKeyToHookSourceAndMetadata.forEach( + (hookSourceAndMetadata, locationKey) => { + const hookParsedMetadata: HookParsedMetadata = { + metadataConsumer: null, + originalSourceAST: null, + originalSourceCode: null, + originalSourceURL: null, + sourceConsumer: null, + }; + + locationKeyToHookParsedMetadata.set(locationKey, hookParsedMetadata); + + const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL; + + // If we've already loaded the source map info for this file, + // we can skip reloading it (and more importantly, re-parsing it). + const runtimeMetadata = runtimeURLToMetadataCache.get(runtimeSourceURL); + if (runtimeMetadata != null) { + if (__DEBUG__) { + console.groupCollapsed( + `parseHookNames() Found cached runtime metadata for file "${runtimeSourceURL}"`, + ); + console.log(runtimeMetadata); + console.groupEnd(); + } + hookParsedMetadata.sourceConsumer = runtimeMetadata.sourceConsumer; + hookParsedMetadata.metadataConsumer = runtimeMetadata.metadataConsumer; + } + }, + ); + + return locationKeyToHookParsedMetadata; +} + +function parseSourceAST( + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, + locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata, +): void { + locationKeyToHookSourceAndMetadata.forEach( + (hookSourceAndMetadata, locationKey) => { + const hookParsedMetadata = locationKeyToHookParsedMetadata.get( + locationKey, + ); + if (hookParsedMetadata == null) { + throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`); + } + + if (hookParsedMetadata.originalSourceAST !== null) { + // Use cached metadata. + return; + } + + const {metadataConsumer, sourceConsumer} = hookParsedMetadata; + const runtimeSourceCode = ((hookSourceAndMetadata.runtimeSourceCode: any): string); + let hasHookMap = false; + let originalSourceURL; + let originalSourceCode; + if (sourceConsumer !== null) { + // Parse and extract the AST from the source map. + const {lineNumber, columnNumber} = hookSourceAndMetadata.hookSource; + if (lineNumber == null || columnNumber == null) { + throw Error('Hook source code location not found.'); + } + // Now that the source map has been loaded, + // extract the original source for later. + // TODO (named hooks) Refactor this read, github.com/facebook/react/pull/22181 + const {source} = withSyncPerformanceMark( + 'sourceConsumer.originalPositionFor()', + () => + sourceConsumer.originalPositionFor({ + line: lineNumber, + + // Column numbers are represented differently between tools/engines. + // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based. + // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991 + column: columnNumber - 1, + }), + ); + + if (source == null) { + // TODO (named hooks) maybe fall back to the runtime source instead of throwing? + throw new Error( + 'Could not map hook runtime location to original source location', + ); + } + + // TODO (named hooks) maybe canonicalize this URL somehow? + // It can be relative if the source map specifies it that way, + // but we use it as a cache key across different source maps and there can be collisions. + originalSourceURL = (source: string); + originalSourceCode = withSyncPerformanceMark( + 'sourceConsumer.sourceContentFor()', + () => (sourceConsumer.sourceContentFor(source, true): string), + ); + + if (__DEBUG__) { + console.groupCollapsed( + 'parseSourceAST() Extracted source code from source map', + ); + console.log(originalSourceCode); + console.groupEnd(); + } + + if ( + metadataConsumer != null && + metadataConsumer.hasHookMap(originalSourceURL) + ) { + hasHookMap = true; + } + } else { + // There's no source map to parse here so we can just parse the original source itself. + originalSourceCode = runtimeSourceCode; + // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space. + // Namespace them? + originalSourceURL = hookSourceAndMetadata.runtimeSourceURL; + } + + hookParsedMetadata.originalSourceCode = originalSourceCode; + hookParsedMetadata.originalSourceURL = originalSourceURL; + + if (hasHookMap) { + // If there's a hook map present from an extended sourcemap then + // we don't need to parse the source files and instead can use the + // hook map to extract hook names. + return; + } + + // The cache also serves to deduplicate parsing by URL in our loop over location keys. + // This may need to change if we switch to async parsing. + const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL); + if (sourceMetadata != null) { + if (__DEBUG__) { + console.groupCollapsed( + `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`, + ); + console.log(sourceMetadata); + console.groupEnd(); + } + hookParsedMetadata.originalSourceAST = sourceMetadata.originalSourceAST; + hookParsedMetadata.originalSourceCode = + sourceMetadata.originalSourceCode; + } else { + // TypeScript is the most commonly used typed JS variant so let's default to it + // unless we detect explicit Flow usage via the "@flow" pragma. + const plugin = + originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript'; + + // TODO (named hooks) This is probably where we should check max source length, + // rather than in loadSourceAndMetatada -> loadSourceFiles(). + const originalSourceAST = withSyncPerformanceMark( + '[@babel/parser] parse(originalSourceCode)', + () => + parse(originalSourceCode, { + sourceType: 'unambiguous', + plugins: ['jsx', plugin], + }), + ); + hookParsedMetadata.originalSourceAST = originalSourceAST; + + if (__DEBUG__) { + console.log( + `parseSourceAST() Caching source metadata for "${originalSourceURL}"`, + ); + } + + originalURLToMetadataCache.set(originalSourceURL, { + originalSourceAST, + originalSourceCode, + }); + } + }, + ); +} + +function parseSourceMaps( + locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, + locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata, +) { + locationKeyToHookSourceAndMetadata.forEach( + (hookSourceAndMetadata, locationKey) => { + const hookParsedMetadata = locationKeyToHookParsedMetadata.get( + locationKey, + ); + if (hookParsedMetadata == null) { + throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`); + } + + const sourceMapJSON = hookSourceAndMetadata.sourceMapJSON; + if (sourceMapJSON != null) { + hookParsedMetadata.metadataConsumer = withSyncPerformanceMark( + 'new SourceMapMetadataConsumer(sourceMapJSON)', + () => new SourceMapMetadataConsumer(sourceMapJSON), + ); + hookParsedMetadata.sourceConsumer = withSyncPerformanceMark( + 'new SourceMapConsumer(sourceMapJSON)', + () => new SourceMapConsumer(sourceMapJSON), + ); + + const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL; + + // Only set once to avoid triggering eviction/cleanup code. + if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) { + if (__DEBUG__) { + console.log( + `parseSourceMaps() Caching runtime metadata for "${runtimeSourceURL}"`, + ); + } + + runtimeURLToMetadataCache.set(runtimeSourceURL, { + metadataConsumer: hookParsedMetadata.metadataConsumer, + sourceConsumer: hookParsedMetadata.sourceConsumer, + }); + } + } + }, + ); +} + +export function purgeCachedMetadata(): void { + originalURLToMetadataCache.reset(); + runtimeURLToMetadataCache.reset(); +} diff --git a/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js new file mode 100644 index 0000000000000..b2e0f21425844 --- /dev/null +++ b/packages/react-devtools-extensions/src/parseHookNames/parseSourceAndMetadata.worker.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as parseSourceAndMetadataModule from './parseSourceAndMetadata'; + +export const parseSourceAndMetadata = + parseSourceAndMetadataModule.parseSourceAndMetadata; +export const purgeCachedMetadata = + parseSourceAndMetadataModule.purgeCachedMetadata; diff --git a/packages/react-devtools-shared/src/PerformanceMarks.js b/packages/react-devtools-shared/src/PerformanceMarks.js index 0c94b9d4f2593..e702d3923f1df 100644 --- a/packages/react-devtools-shared/src/PerformanceMarks.js +++ b/packages/react-devtools-shared/src/PerformanceMarks.js @@ -9,13 +9,22 @@ import {__PERFORMANCE_PROFILE__} from './constants'; +const supportsUserTiming = + typeof performance !== 'undefined' && + typeof performance.mark === 'function' && + typeof performance.clearMarks === 'function'; + function mark(markName: string): void { - performance.mark(markName + '-start'); + if (supportsUserTiming) { + performance.mark(markName + '-start'); + } } function measure(markName: string): void { - performance.mark(markName + '-end'); - performance.measure(markName, markName + '-start', markName + '-end'); + if (supportsUserTiming) { + performance.mark(markName + '-end'); + performance.measure(markName, markName + '-start', markName + '-end'); + } } export async function withAsyncPerformanceMark( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js index bcd0f940751b8..f9f295c7eb43d 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js @@ -2,17 +2,23 @@ import {createContext} from 'react'; import type { + FetchFileWithCaching, LoadHookNamesFunction, + PrefetchSourceFiles, PurgeCachedHookNamesMetadata, } from '../DevTools'; export type Context = { + fetchFileWithCaching: FetchFileWithCaching | null, loadHookNames: LoadHookNamesFunction | null, + prefetchSourceFiles: PrefetchSourceFiles | null, purgeCachedMetadata: PurgeCachedHookNamesMetadata | null, }; const HookNamesContext = createContext({ + fetchFileWithCaching: null, loadHookNames: null, + prefetchSourceFiles: null, purgeCachedMetadata: null, }); HookNamesContext.displayName = 'HookNamesContext'; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index e7b15b3f3dc5e..967196a3b9052 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -16,6 +16,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import {TreeStateContext} from './TreeContext'; @@ -64,7 +65,9 @@ export type Props = {| export function InspectedElementContextController({children}: Props) { const {selectedElementID} = useContext(TreeStateContext); const { + fetchFileWithCaching, loadHookNames: loadHookNamesFunction, + prefetchSourceFiles, purgeCachedMetadata, } = useContext(HookNamesContext); const bridge = useContext(BridgeContext); @@ -126,6 +129,7 @@ export function InspectedElementContextController({children}: Props) { element, inspectedElement.hooks, loadHookNamesFunction, + fetchFileWithCaching, ); } } @@ -151,6 +155,21 @@ export function InspectedElementContextController({children}: Props) { [setState, state], ); + const inspectedElementRef = useRef(null); + useEffect(() => { + if ( + inspectedElement !== null && + inspectedElement.hooks !== null && + inspectedElementRef.current !== inspectedElement + ) { + inspectedElementRef.current = inspectedElement; + + if (typeof prefetchSourceFiles === 'function') { + prefetchSourceFiles(inspectedElement.hooks, fetchFileWithCaching); + } + } + }, [inspectedElement, prefetchSourceFiles]); + useEffect(() => { if (typeof purgeCachedMetadata === 'function') { // When Fast Refresh updates a component, any cached AST metadata may be invalid. diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index 17a88d44cb5b9..a1280d14bd224 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -51,6 +51,11 @@ import type {Thenable} from '../cache'; export type BrowserTheme = 'dark' | 'light'; export type TabID = 'components' | 'profiler'; +export type FetchFileWithCaching = (url: string) => Promise; +export type PrefetchSourceFiles = ( + hooksTree: HooksTree, + fetchFileWithCaching: FetchFileWithCaching | null, +) => void; export type ViewElementSource = ( id: number, inspectedElement: InspectedElement, @@ -101,7 +106,9 @@ export type Props = {| // Loads and parses source maps for function components // and extracts hook "names" based on the variables the hook return values get assigned to. // Not every DevTools build can load source maps, so this property is optional. + fetchFileWithCaching?: ?FetchFileWithCaching, loadHookNames?: ?LoadHookNamesFunction, + prefetchSourceFiles?: ?PrefetchSourceFiles, purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata, |}; @@ -127,9 +134,11 @@ export default function DevTools({ componentsPortalContainer, defaultTab = 'components', enabledInspectedElementContextMenu = false, + fetchFileWithCaching, loadHookNames, overrideTab, profilerPortalContainer, + prefetchSourceFiles, purgeCachedHookNamesMetadata, showTabBar = false, store, @@ -192,10 +201,17 @@ export default function DevTools({ const hookNamesContext = useMemo( () => ({ + fetchFileWithCaching: fetchFileWithCaching || null, loadHookNames: loadHookNames || null, + prefetchSourceFiles: prefetchSourceFiles || null, purgeCachedMetadata: purgeCachedHookNamesMetadata || null, }), - [loadHookNames, purgeCachedHookNamesMetadata], + [ + fetchFileWithCaching, + loadHookNames, + prefetchSourceFiles, + purgeCachedHookNamesMetadata, + ], ); const devToolsRef = useRef(null); diff --git a/packages/react-devtools-shared/src/hookNamesCache.js b/packages/react-devtools-shared/src/hookNamesCache.js index 070dafeb1d8eb..6edbc0e30a12e 100644 --- a/packages/react-devtools-shared/src/hookNamesCache.js +++ b/packages/react-devtools-shared/src/hookNamesCache.js @@ -17,6 +17,7 @@ import type { HookSourceLocationKey, } from 'react-devtools-shared/src/types'; import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks'; +import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/DevTools'; const TIMEOUT = 30000; @@ -53,6 +54,11 @@ function readRecord(record: Record): ResolvedRecord | RejectedRecord { } } +type LoadHookNamesFunction = ( + hookLog: HooksTree, + fetchFileWithCaching: FetchFileWithCaching | null, +) => Thenable; + // This is intentionally a module-level Map, rather than a React-managed one. // Otherwise, refreshing the inspected element cache would also clear this cache. // TODO Rethink this if the React API constraints change. @@ -67,7 +73,8 @@ export function hasAlreadyLoadedHookNames(element: Element): boolean { export function loadHookNames( element: Element, hooksTree: HooksTree, - loadHookNamesFunction: (hookLog: HooksTree) => Thenable, + loadHookNamesFunction: LoadHookNamesFunction, + fetchFileWithCaching: FetchFileWithCaching | null, ): HookNames | null { let record = map.get(element); @@ -103,7 +110,7 @@ export function loadHookNames( let didTimeout = false; - loadHookNamesFunction(hooksTree).then( + loadHookNamesFunction(hooksTree, fetchFileWithCaching).then( function onSuccess(hookNames) { if (didTimeout) { return;