From 93d0e48a7fff345e856d3392447b7762e44e6a03 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 28 Aug 2021 10:24:59 -0700 Subject: [PATCH 01/11] DevTools: Separate named hooks I/O and CPU work by thread 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 so we can benefit from Network caching for source files. Note that this change alone is not sufficient to be able to share the Network cache, but it will help making the subsequent work easier. --- .../src/__tests__/parseHookNames-test.js | 35 +- .../src/parseHookNames/index.js | 44 +- .../parseHookNames/loadSourceAndMetadata.js | 432 ++++++++++ .../src/parseHookNames/parseHookNames.js | 750 ------------------ .../parseHookNames/parseHookNames.worker.js | 11 - .../parseHookNames/parseSourceAndMetadata.js | 458 +++++++++++ .../parseSourceAndMetadata.worker.js | 13 + .../src/PerformanceMarks.js | 15 +- .../react-devtools-shared/src/constants.js | 2 +- 9 files changed, 979 insertions(+), 781 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..24c8239da47f6 100644 --- a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js +++ b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js @@ -42,7 +42,24 @@ describe('parseHookNames', () => { inspectHooks = require('react-debug-tools/src/ReactDebugHooks') .inspectHooks; - parseHookNames = require('../parseHookNames/parseHookNames').parseHookNames; + + // Jest can't run the workerized version of this module. + const loadSourceAndMetadata = require('../parseHookNames/loadSourceAndMetadata') + .default; + const parseSourceAndMetadata = require('../parseHookNames/parseSourceAndMetadata') + .parseSourceAndMetadata; + parseHookNames = async hooksTree => { + const [ + hooksList, + locationKeyToHookSourceAndMetadata, + ] = await loadSourceAndMetadata(hooksTree); + + // 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. @@ -880,18 +897,21 @@ describe('parseHookNames', () => { describe('parseHookNames worker', () => { let inspectHooks; let parseHookNames; - let workerizedParseHookNamesMock; + let workerizedParseSourceAndMetadataMock; beforeEach(() => { window.Worker = undefined; - workerizedParseHookNamesMock = jest.fn(); + workerizedParseSourceAndMetadataMock = jest.fn(() => { + console.log('mock fn'); + return []; + }); - jest.mock('../parseHookNames/parseHookNames.worker.js', () => { + jest.mock('../parseHookNames/parseSourceAndMetadata.worker.js', () => { return { __esModule: true, default: () => ({ - parseHookNames: workerizedParseHookNamesMock, + parseSourceAndMetadata: workerizedParseSourceAndMetadataMock, }), }; }); @@ -912,11 +932,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/parseHookNames/index.js b/packages/react-devtools-extensions/src/parseHookNames/index.js index 643655ae757e0..caf0f63f01f11 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/index.js +++ b/packages/react-devtools-extensions/src/parseHookNames/index.js @@ -7,17 +7,43 @@ * @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 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 loadSourceAndMetadata from './loadSourceAndMetadata'; -const workerizedParseHookNames: ParseHookNamesModule = WorkerizedParseHookNames(); +const workerizedParseHookNames: ParseSourceAndMetadataModule = WorkerizedParseSourceAndMetadata(); -type ParseHookNames = $PropertyType; - -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; + +export async function parseHookNames( + hooksTree: HooksTree, +): Promise { + return withAsyncPerformanceMark('parseHookNames', async () => { + // Runs on the main/UI thread so it can reuse Network cache: + const [ + hooksList, + locationKeyToHookSourceAndMetadata, + ] = await loadSourceAndMetadata(hooksTree); + + // 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..0d7c23867854d --- /dev/null +++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js @@ -0,0 +1,432 @@ +/** + * 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 always reuse the browser's Network cache. +// Requests made from within a Worker might not reuse this cache (at least in Chrome 92). +// +// Some overhead may be incurred sharing the source data with the Worker, +// 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 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. +// +// b. If no React metadata, 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. +// +// c. 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. + +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 { + HooksNode, + HookSource, + HooksTree, +} from 'react-debug-tools/src/ReactDebugHooks'; +import type {MixedSourceMap} from 'react-devtools-extensions/src/SourceMapTypes'; + +// 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_WITH_CACHE_OPTIONS = {cache: 'force-cache'}; + +const MAX_SOURCE_LENGTH = 100_000_000; + +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 default async function loadSourceAndMetadata( + hooksTree: HooksTree, +): Promise<[HooksList, LocationKeyToHookSourceAndMetadata]> { + return withAsyncPerformanceMark('loadSourceAndMetadata()', async () => { + const hooksList: HooksList = []; + withSyncPerformanceMark('flattenHooksList()', () => { + flattenHooksList(hooksTree, hooksList); + }); + + if (__DEBUG__) { + console.log('loadSourceAndMetadata() hooksList:', hooksList); + } + + const locationKeyToHookSourceAndMetadata = withSyncPerformanceMark( + 'initializeHookSourceAndMetadata', + () => initializeHookSourceAndMetadata(hooksList), + ); + + await withAsyncPerformanceMark('loadSourceFiles()', () => + loadSourceFiles(locationKeyToHookSourceAndMetadata), + ); + + 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 [hooksList, 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; + + 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 => { + hookSourceAndMetadata.sourceMapJSON = sourceMapJSON; + }), + ); + }); + } + } + }); + + return Promise.all(setterPromises); +} + +function fetchFile(url: string): Promise { + return withCallbackPerformanceMark(`fetchFile("${url}")`, done => { + return new Promise((resolve, reject) => { + fetch(url, FETCH_WITH_CACHE_OPTIONS).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 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); + } + } +} + +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, +): 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 fetchPromise = + dedupedFetchPromises.get(runtimeSourceURL) || + fetchFile(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); +} 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/constants.js b/packages/react-devtools-shared/src/constants.js index 29d805890dedb..fa0814d06e32e 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -11,7 +11,7 @@ export const __DEBUG__ = false; // Flip this flag to true to enable performance.mark() and performance.measure() timings. -export const __PERFORMANCE_PROFILE__ = false; +export const __PERFORMANCE_PROFILE__ = true; export const TREE_OPERATION_ADD = 1; export const TREE_OPERATION_REMOVE = 2; From 92b9ccb650383655fd1124768f569c5d654ceb04 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 28 Aug 2021 10:36:23 -0700 Subject: [PATCH 02/11] Small optimization to avoid serializing runtime source unnecessarily For source that have source maps, the original source is retrieved using the source map. 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 to share it with the worker in this case. --- .../parseHookNames/loadSourceAndMetadata.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js index 0d7c23867854d..eb7ace99e1655 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js +++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js @@ -200,6 +200,13 @@ function extractAndLoadSourceMapJSON( 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 { @@ -272,7 +279,16 @@ function extractAndLoadSourceMapJSON( setterPromises.push( fetchPromise.then(sourceMapJSON => { - hookSourceAndMetadata.sourceMapJSON = 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; + } }), ); }); From fa45ab3c7aba4143b8b8d3fafe8684461cd55c84 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 30 Aug 2021 09:44:23 -0700 Subject: [PATCH 03/11] Move source file fetching into content script for better cache utilization Network requests made from an extension do not reuse the page's Network cache. To work around that, this commit adds a helper function to the content script (which runs in the page's context) that the extension can communicate with via postMessage. The content script can then fetch cached files for the extension. --- .../src/background.js | 17 +++- .../src/injectGlobalHook.js | 79 +++++++++++++++---- .../react-devtools-extensions/src/main.js | 36 +++++++++ .../src/parseHookNames/index.js | 4 +- .../parseHookNames/loadSourceAndMetadata.js | 30 +++++-- .../views/Components/HookNamesContext.js | 3 + .../Components/InspectedElementContext.js | 2 + .../src/devtools/views/DevTools.js | 6 +- .../src/hookNamesCache.js | 11 ++- 9 files changed, 160 insertions(+), 28 deletions(-) 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..153da4ee1d569 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -212,6 +212,41 @@ function createPanelIfReactLoaded() { } }; + // Fetching files from the extension won't make use of the network cache + // for resources that have already been loaded by the page. + // This helper function allows the extension to request files to be fetched + // by the content script (running in the page) to increase the likelihood of a cache hit. + const fetchFileWithCaching = url => { + 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) => { @@ -224,6 +259,7 @@ function createPanelIfReactLoaded() { browserTheme: getBrowserTheme(), componentsPortalContainer, enabledInspectedElementContextMenu: true, + fetchFileWithCaching, loadHookNames: parseHookNames, overrideTab, profilerPortalContainer, diff --git a/packages/react-devtools-extensions/src/parseHookNames/index.js b/packages/react-devtools-extensions/src/parseHookNames/index.js index caf0f63f01f11..8afc3dcb16fa4 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/index.js +++ b/packages/react-devtools-extensions/src/parseHookNames/index.js @@ -10,6 +10,7 @@ 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 {withAsyncPerformanceMark} from 'react-devtools-shared/src/PerformanceMarks'; import WorkerizedParseSourceAndMetadata from './parseSourceAndMetadata.worker'; @@ -32,13 +33,14 @@ export const purgeCachedMetadata = workerizedParseHookNames.purgeCachedMetadata; export async function parseHookNames( hooksTree: HooksTree, + fetchFileWithCaching: FetchFileWithCaching | null, ): Promise { return withAsyncPerformanceMark('parseHookNames', async () => { // Runs on the main/UI thread so it can reuse Network cache: const [ hooksList, locationKeyToHookSourceAndMetadata, - ] = await loadSourceAndMetadata(hooksTree); + ] = await loadSourceAndMetadata(hooksTree, fetchFileWithCaching); // Runs in a Worker because it's CPU intensive: return parseSourceAndMetadata( diff --git a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js index eb7ace99e1655..6c5cde7d3cffe 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js +++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js @@ -11,10 +11,12 @@ // 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 always reuse the browser's Network cache. -// Requests made from within a Worker might not reuse this cache (at least in Chrome 92). +// 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 the source data with the Worker, +// 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). @@ -55,6 +57,7 @@ import type { 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). @@ -90,6 +93,7 @@ export type HooksList = Array; export default async function loadSourceAndMetadata( hooksTree: HooksTree, + fetchFileWithCaching: FetchFileWithCaching | null, ): Promise<[HooksList, LocationKeyToHookSourceAndMetadata]> { return withAsyncPerformanceMark('loadSourceAndMetadata()', async () => { const hooksList: HooksList = []; @@ -107,7 +111,7 @@ export default async function loadSourceAndMetadata( ); await withAsyncPerformanceMark('loadSourceFiles()', () => - loadSourceFiles(locationKeyToHookSourceAndMetadata), + loadSourceFiles(locationKeyToHookSourceAndMetadata, fetchFileWithCaching), ); await withAsyncPerformanceMark('extractAndLoadSourceMapJSON()', () => @@ -409,6 +413,7 @@ function isUnnamedBuiltInHook(hook: HooksNode) { function loadSourceFiles( locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, + fetchFileWithCaching: FetchFileWithCaching | null, ): Promise<*> { // Deduplicate fetches, since there can be multiple location keys per file. const dedupedFetchPromises = new Map(); @@ -416,9 +421,24 @@ function loadSourceFiles( const setterPromises = []; locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => { const {runtimeSourceURL} = hookSourceAndMetadata; + + 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) || - fetchFile(runtimeSourceURL).then(runtimeSourceCode => { + 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) { 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..12076e5c7c12a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js @@ -2,16 +2,19 @@ import {createContext} from 'react'; import type { + FetchFileWithCaching, LoadHookNamesFunction, PurgeCachedHookNamesMetadata, } from '../DevTools'; export type Context = { + fetchFileWithCaching: FetchFileWithCaching | null, loadHookNames: LoadHookNamesFunction | null, purgeCachedMetadata: PurgeCachedHookNamesMetadata | null, }; const HookNamesContext = createContext({ + fetchFileWithCaching: null, loadHookNames: null, purgeCachedMetadata: null, }); 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..11f63fb6737d5 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -64,6 +64,7 @@ export type Props = {| export function InspectedElementContextController({children}: Props) { const {selectedElementID} = useContext(TreeStateContext); const { + fetchFileWithCaching, loadHookNames: loadHookNamesFunction, purgeCachedMetadata, } = useContext(HookNamesContext); @@ -126,6 +127,7 @@ export function InspectedElementContextController({children}: Props) { element, inspectedElement.hooks, loadHookNamesFunction, + fetchFileWithCaching, ); } } diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index 17a88d44cb5b9..8c205a74cd8ff 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -51,6 +51,7 @@ import type {Thenable} from '../cache'; export type BrowserTheme = 'dark' | 'light'; export type TabID = 'components' | 'profiler'; +export type FetchFileWithCaching = (url: string) => Promise; export type ViewElementSource = ( id: number, inspectedElement: InspectedElement, @@ -101,6 +102,7 @@ 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, purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata, |}; @@ -127,6 +129,7 @@ export default function DevTools({ componentsPortalContainer, defaultTab = 'components', enabledInspectedElementContextMenu = false, + fetchFileWithCaching, loadHookNames, overrideTab, profilerPortalContainer, @@ -192,10 +195,11 @@ export default function DevTools({ const hookNamesContext = useMemo( () => ({ + fetchFileWithCaching: fetchFileWithCaching || null, loadHookNames: loadHookNames || null, purgeCachedMetadata: purgeCachedHookNamesMetadata || null, }), - [loadHookNames, purgeCachedHookNamesMetadata], + [fetchFileWithCaching, loadHookNames, 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; From 6f59b586a5d7fbddd70ef5ea8fbe9d06f6e1645b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 30 Aug 2021 15:49:56 -0700 Subject: [PATCH 04/11] Optimization: Don't invoke worker code when there are no named hooks This waste a good bit of time for nothing. --- .../src/__tests__/parseHookNames-test.js | 14 ++++--- .../src/parseHookNames/index.js | 16 ++++++-- .../parseHookNames/loadSourceAndMetadata.js | 38 +++++++++++-------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js index 24c8239da47f6..6d14f75a0f64d 100644 --- a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js +++ b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js @@ -44,15 +44,19 @@ describe('parseHookNames', () => { .inspectHooks; // Jest can't run the workerized version of this module. - const loadSourceAndMetadata = require('../parseHookNames/loadSourceAndMetadata') - .default; + const { + flattenHooksList, + loadSourceAndMetadata, + } = require('../parseHookNames/loadSourceAndMetadata'); const parseSourceAndMetadata = require('../parseHookNames/parseSourceAndMetadata') .parseSourceAndMetadata; parseHookNames = async hooksTree => { - const [ + const hooksList = flattenHooksList(hooksTree); + + // Runs in the UI thread so it can share Network cache: + const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata( hooksList, - locationKeyToHookSourceAndMetadata, - ] = await loadSourceAndMetadata(hooksTree); + ); // Runs in a Worker because it's CPU intensive: return parseSourceAndMetadata( diff --git a/packages/react-devtools-extensions/src/parseHookNames/index.js b/packages/react-devtools-extensions/src/parseHookNames/index.js index 8afc3dcb16fa4..c5e790192261a 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/index.js +++ b/packages/react-devtools-extensions/src/parseHookNames/index.js @@ -15,7 +15,7 @@ import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/view import {withAsyncPerformanceMark} from 'react-devtools-shared/src/PerformanceMarks'; import WorkerizedParseSourceAndMetadata from './parseSourceAndMetadata.worker'; import typeof * as ParseSourceAndMetadataModule from './parseSourceAndMetadata'; -import loadSourceAndMetadata from './loadSourceAndMetadata'; +import {flattenHooksList, loadSourceAndMetadata} from './loadSourceAndMetadata'; const workerizedParseHookNames: ParseSourceAndMetadataModule = WorkerizedParseSourceAndMetadata(); @@ -31,16 +31,24 @@ export function parseSourceAndMetadata( 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 [ + const locationKeyToHookSourceAndMetadata = await loadSourceAndMetadata( hooksList, - locationKeyToHookSourceAndMetadata, - ] = await loadSourceAndMetadata(hooksTree, fetchFileWithCaching); + fetchFileWithCaching, + ); // Runs in a Worker because it's CPU intensive: return parseSourceAndMetadata( diff --git a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js index 6c5cde7d3cffe..31cc62ebfa013 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js +++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js @@ -91,20 +91,11 @@ export type LocationKeyToHookSourceAndMetadata = Map< >; export type HooksList = Array; -export default async function loadSourceAndMetadata( - hooksTree: HooksTree, +export async function loadSourceAndMetadata( + hooksList: HooksList, fetchFileWithCaching: FetchFileWithCaching | null, -): Promise<[HooksList, LocationKeyToHookSourceAndMetadata]> { +): Promise { return withAsyncPerformanceMark('loadSourceAndMetadata()', async () => { - const hooksList: HooksList = []; - withSyncPerformanceMark('flattenHooksList()', () => { - flattenHooksList(hooksTree, hooksList); - }); - - if (__DEBUG__) { - console.log('loadSourceAndMetadata() hooksList:', hooksList); - } - const locationKeyToHookSourceAndMetadata = withSyncPerformanceMark( 'initializeHookSourceAndMetadata', () => initializeHookSourceAndMetadata(hooksList), @@ -120,7 +111,7 @@ export default async function loadSourceAndMetadata( // 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 [hooksList, locationKeyToHookSourceAndMetadata]; + return locationKeyToHookSourceAndMetadata; }); } @@ -344,7 +335,20 @@ function fetchFile(url: string): Promise { }); } -function flattenHooksList( +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 { @@ -354,14 +358,16 @@ function flattenHooksList( 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); + console.log('flattenHooksListImpl() Skipping unnamed hook', hook); } + continue; } hooksList.push(hook); + if (hook.subHooks.length > 0) { - flattenHooksList(hook.subHooks, hooksList); + flattenHooksListImpl(hook.subHooks, hooksList); } } } From ac4c95dbf8bed4f94569f64ed1d5b024fc27741b Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 30 Aug 2021 16:10:51 -0700 Subject: [PATCH 05/11] Fixed failing test --- .../src/__tests__/parseHookNames-test.js | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js b/packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js index 6d14f75a0f64d..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,8 +54,7 @@ describe('parseHookNames', () => { console.trace('source-map-support'); }); - fetchMock = require('jest-fetch-mock'); - fetchMock.enableMocks(); + fetchMock = initFetchMock(); inspectHooks = require('react-debug-tools/src/ReactDebugHooks') .inspectHooks; @@ -76,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(() => { @@ -906,10 +910,9 @@ describe('parseHookNames worker', () => { beforeEach(() => { window.Worker = undefined; - workerizedParseSourceAndMetadataMock = jest.fn(() => { - console.log('mock fn'); - return []; - }); + workerizedParseSourceAndMetadataMock = jest.fn(); + + initFetchMock(); jest.mock('../parseHookNames/parseSourceAndMetadata.worker.js', () => { return { From 1184d5b2450ece4c24b140ebcba87982b34becec Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 Aug 2021 15:22:05 -0700 Subject: [PATCH 06/11] Pre-fetch (and cache) source files on component inspection This reduces the impact for sites with CORS policies that prevent us from using the Network cache. --- .../react-devtools-extensions/src/main.js | 3 +- .../src/parseHookNames/index.js | 8 +- .../parseHookNames/loadSourceAndMetadata.js | 179 +++++++++++++----- .../views/Components/HookNamesContext.js | 3 + .../Components/InspectedElementContext.js | 17 ++ .../src/devtools/views/DevTools.js | 14 +- 6 files changed, 178 insertions(+), 46 deletions(-) diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 153da4ee1d569..e07dd4568466e 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -252,7 +252,7 @@ function createPanelIfReactLoaded() { render = (overrideTab = mostRecentOverrideTab) => { mostRecentOverrideTab = overrideTab; import('./parseHookNames').then( - ({parseHookNames, purgeCachedMetadata}) => { + ({parseHookNames, prefetchSourceFiles, purgeCachedMetadata}) => { root.render( createElement(DevTools, { bridge, @@ -262,6 +262,7 @@ function createPanelIfReactLoaded() { fetchFileWithCaching, loadHookNames: parseHookNames, overrideTab, + prefetchSourceFiles, profilerPortalContainer, purgeCachedHookNamesMetadata: purgeCachedMetadata, showTabBar: false, diff --git a/packages/react-devtools-extensions/src/parseHookNames/index.js b/packages/react-devtools-extensions/src/parseHookNames/index.js index c5e790192261a..eae8440399c7b 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/index.js +++ b/packages/react-devtools-extensions/src/parseHookNames/index.js @@ -15,10 +15,16 @@ import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/view import {withAsyncPerformanceMark} from 'react-devtools-shared/src/PerformanceMarks'; import WorkerizedParseSourceAndMetadata from './parseSourceAndMetadata.worker'; import typeof * as ParseSourceAndMetadataModule from './parseSourceAndMetadata'; -import {flattenHooksList, loadSourceAndMetadata} from './loadSourceAndMetadata'; +import { + flattenHooksList, + loadSourceAndMetadata, + prefetchSourceFiles, +} from './loadSourceAndMetadata'; const workerizedParseHookNames: ParseSourceAndMetadataModule = WorkerizedParseSourceAndMetadata(); +export {prefetchSourceFiles}; + export function parseSourceAndMetadata( hooksList: Array, locationKeyToHookSourceAndMetadata: Map, diff --git a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js index 31cc62ebfa013..95c3463318759 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js +++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js @@ -42,6 +42,7 @@ // Use the source to infer hook names. // This is the least optimal route as parsing the full source is very CPU intensive. +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'; @@ -51,6 +52,7 @@ import { withSyncPerformanceMark, } from 'react-devtools-shared/src/PerformanceMarks'; +import type {LRUCache} from 'react-devtools-shared/src/types'; import type { HooksNode, HookSource, @@ -61,10 +63,18 @@ import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/view // 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_WITH_CACHE_OPTIONS = {cache: 'force-cache'}; +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, @@ -294,10 +304,13 @@ function extractAndLoadSourceMapJSON( return Promise.all(setterPromises); } -function fetchFile(url: string): Promise { - return withCallbackPerformanceMark(`fetchFile("${url}")`, done => { +function fetchFile( + url: string, + markName?: string = 'fetchFile', +): Promise { + return withCallbackPerformanceMark(`${markName}("${url}")`, done => { return new Promise((resolve, reject) => { - fetch(url, FETCH_WITH_CACHE_OPTIONS).then( + fetch(url, FETCH_OPTIONS).then( response => { if (response.ok) { response @@ -309,7 +322,7 @@ function fetchFile(url: string): Promise { .catch(error => { if (__DEBUG__) { console.log( - `fetchFile() Could not read text for url "${url}"`, + `${markName}() Could not read text for url "${url}"`, ); } done(); @@ -317,7 +330,7 @@ function fetchFile(url: string): Promise { }); } else { if (__DEBUG__) { - console.log(`fetchFile() Got bad response for url "${url}"`); + console.log(`${markName}() Got bad response for url "${url}"`); } done(); reject(null); @@ -325,7 +338,7 @@ function fetchFile(url: string): Promise { }, error => { if (__DEBUG__) { - console.log(`fetchFile() Could not fetch file: ${error.message}`); + console.log(`${markName}() Could not fetch file: ${error.message}`); } done(); reject(null); @@ -335,6 +348,24 @@ function fetchFile(url: string): Promise { }); } +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()', () => { @@ -428,47 +459,109 @@ function loadSourceFiles( locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => { const {runtimeSourceURL} = hookSourceAndMetadata; - 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 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'); - } + 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(); - } + if (__DEBUG__) { + console.groupCollapsed( + `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, + ); + console.log(runtimeSourceCode); + console.groupEnd(); + } - return runtimeSourceCode; - }); - dedupedFetchPromises.set(runtimeSourceURL, fetchPromise); + return runtimeSourceCode; + }); + dedupedFetchPromises.set(runtimeSourceURL, fetchPromise); - setterPromises.push( - fetchPromise.then(runtimeSourceCode => { - hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode; - }), - ); + 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-shared/src/devtools/views/Components/HookNamesContext.js b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js index 12076e5c7c12a..f9f295c7eb43d 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/HookNamesContext.js @@ -4,18 +4,21 @@ 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 11f63fb6737d5..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'; @@ -66,6 +67,7 @@ export function InspectedElementContextController({children}: Props) { const { fetchFileWithCaching, loadHookNames: loadHookNamesFunction, + prefetchSourceFiles, purgeCachedMetadata, } = useContext(HookNamesContext); const bridge = useContext(BridgeContext); @@ -153,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 8c205a74cd8ff..a1280d14bd224 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -52,6 +52,10 @@ 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, @@ -104,6 +108,7 @@ export type Props = {| // Not every DevTools build can load source maps, so this property is optional. fetchFileWithCaching?: ?FetchFileWithCaching, loadHookNames?: ?LoadHookNamesFunction, + prefetchSourceFiles?: ?PrefetchSourceFiles, purgeCachedHookNamesMetadata?: ?PurgeCachedHookNamesMetadata, |}; @@ -133,6 +138,7 @@ export default function DevTools({ loadHookNames, overrideTab, profilerPortalContainer, + prefetchSourceFiles, purgeCachedHookNamesMetadata, showTabBar = false, store, @@ -197,9 +203,15 @@ export default function DevTools({ () => ({ fetchFileWithCaching: fetchFileWithCaching || null, loadHookNames: loadHookNames || null, + prefetchSourceFiles: prefetchSourceFiles || null, purgeCachedMetadata: purgeCachedHookNamesMetadata || null, }), - [fetchFileWithCaching, loadHookNames, purgeCachedHookNamesMetadata], + [ + fetchFileWithCaching, + loadHookNames, + prefetchSourceFiles, + purgeCachedHookNamesMetadata, + ], ); const devToolsRef = useRef(null); From 1335a3e1fa52fd1036ff275637e1f6e7176daad0 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 31 Aug 2021 15:51:46 -0700 Subject: [PATCH 07/11] Disable __PERFORMANCE_PROFILE__ flag --- packages/react-devtools-shared/src/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index fa0814d06e32e..29d805890dedb 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -11,7 +11,7 @@ export const __DEBUG__ = false; // Flip this flag to true to enable performance.mark() and performance.measure() timings. -export const __PERFORMANCE_PROFILE__ = true; +export const __PERFORMANCE_PROFILE__ = false; export const TREE_OPERATION_ADD = 1; export const TREE_OPERATION_REMOVE = 2; From e6595546afb2d15a871f8aba6e33fe338f22dace Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 1 Sep 2021 09:29:00 -0700 Subject: [PATCH 08/11] Use devtools.network.onRequestFinished to cache resources loaded by the page This helps avoid unnecessary duplicate requests when hook names are parsed. Responses with a Vary: 'Origin' migt not match future requests. Caching the requests as they're made lets us avoid a possible (expensive) cache miss. --- .../react-devtools-extensions/src/main.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index e07dd4568466e..939cfb637b013 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -25,6 +25,25 @@ 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' migt 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) { + const {method, url} = event.request; + if (method === 'GET' && url.indexOf('.js') > 0) { + const {mimeType} = event.response.content; + if (mimeType === 'application/x-javascript') { + cachedNetworkEvents.set(url, event); + } + } + }, +); + let panelCreated = false; // The renderer interface can't read saved component filters directly, @@ -217,6 +236,20 @@ function createPanelIfReactLoaded() { // This helper function allows the extension to request files to be fetched // by the content script (running in the page) to increase the likelihood of a cache hit. const fetchFileWithCaching = url => { + 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') { @@ -403,6 +436,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(); @@ -419,6 +455,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(); } From 14535e17306deda128319fec886d2e016d1431e0 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 1 Sep 2021 09:30:58 -0700 Subject: [PATCH 09/11] Updated the header comment for loadSourceAndMetadata --- .../src/parseHookNames/loadSourceAndMetadata.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js index 95c3463318759..dad3b864636b8 100644 --- a/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js +++ b/packages/react-devtools-extensions/src/parseHookNames/loadSourceAndMetadata.js @@ -25,12 +25,7 @@ // 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 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. -// -// b. If no React metadata, search loaded source file to see if a source map is available. +// 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. @@ -38,9 +33,17 @@ // 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. // -// c. If no source map, pass the full source to a Worker for parsing. +// 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'; From 1839d12b5b77cfa2ef3ec55843c34e88fcbbabff Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 1 Sep 2021 10:48:39 -0700 Subject: [PATCH 10/11] Disable fetchFileWithCaching for Firefox For some reason in Firefox, chrome.runtime.sendMessage() from a content script never reaches the chrome.runtime.onMessage event listener. So we'll only pass this function through for Chrome (and Edge). --- .../react-devtools-extensions/src/main.js | 93 ++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 939cfb637b013..a24f170ceb670 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -29,7 +29,7 @@ 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' migt not match future requests. +// 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( @@ -231,54 +231,59 @@ function createPanelIfReactLoaded() { } }; - // Fetching files from the extension won't make use of the network cache - // for resources that have already been loaded by the page. - // This helper function allows the extension to request files to be fetched - // by the content script (running in the page) to increase the likelihood of a cache hit. - const fetchFileWithCaching = url => { - 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)); - }); - } + // 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; + // 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.runtime.onMessage.addListener(onPortMessage); - chrome.devtools.inspectedWindow.eval(` - window.postMessage({ - source: 'react-devtools-extension', - payload: { - type: 'fetch-file-with-cache', - url: "${url}", - }, - }); - `); - }); - }; + chrome.devtools.inspectedWindow.eval(` + window.postMessage({ + source: 'react-devtools-extension', + payload: { + type: 'fetch-file-with-cache', + url: "${url}", + }, + }); + `); + }); + }; + } root = createRoot(document.createElement('div')); From f9b27a61756b822c4497eed63a7a5bc3eac97b8a Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 1 Sep 2021 11:00:10 -0700 Subject: [PATCH 11/11] Expanded mime type check for cached files --- packages/react-devtools-extensions/src/main.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index a24f170ceb670..43ef1a1b26864 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -34,11 +34,13 @@ const cachedNetworkEvents = new Map(); // For more info see: github.com/facebook/react/pull/22198 chrome.devtools.network.onRequestFinished.addListener( function onRequestFinished(event) { - const {method, url} = event.request; - if (method === 'GET' && url.indexOf('.js') > 0) { - const {mimeType} = event.response.content; - if (mimeType === 'application/x-javascript') { - cachedNetworkEvents.set(url, 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; } } },