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<ParseHookNamesModule, 'parseHookNames'>; +export {prefetchSourceFiles}; -export const parseHookNames: ParseHookNames = hooksTree => - workerizedParseHookNames.parseHookNames(hooksTree); +export function parseSourceAndMetadata( + hooksList: Array<HooksNode>, + locationKeyToHookSourceAndMetadata: Map<string, HookSourceAndMetadata>, +): Promise<HookNames | null> { + 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<HookNames | null> { + 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<string, string> = 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<HooksNode>; + +export async function loadSourceAndMetadata( + hooksList: HooksList, + fetchFileWithCaching: FetchFileWithCaching | null, +): Promise<LocationKeyToHookSourceAndMetadata> { + 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<string>)[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<string> { + 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<HooksNode>, +): 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<HooksNode>, +): 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<HookNames | null> { - const hooksList: Array<HooksNode> = []; - withSyncPerformanceMark('flattenHooksList()', () => { - flattenHooksList(hooksTree, hooksList); - }); - - return withAsyncPerformanceMark('parseHookNames()', () => - parseHookNamesImpl(hooksList), - ); -} - -async function parseHookNamesImpl( - hooksList: HooksNode[], -): Promise<HookNames | null> { - 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<string, HookSourceData> = 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<string, HookSourceData>, -): 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<string>)[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<string> { - 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<HooksNode>, - locationKeyToHookSourceData: Map<string, HookSourceData>, -): 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<string, HookSourceData>, -): 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<string, HookSourceData>, -): 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<HooksNode>, -): 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<string, HookSourceData>, -): 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<string, HookParsedMetadata>; + +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<HookNames | null> { + 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<TReturn>( 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<Context>({ + 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<string>; +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<HTMLElement | null>(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<T>(record: Record<T>): ResolvedRecord<T> | RejectedRecord { } } +type LoadHookNamesFunction = ( + hookLog: HooksTree, + fetchFileWithCaching: FetchFileWithCaching | null, +) => Thenable<HookNames>; + // 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<HookNames>, + 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;