diff --git a/packages/react-devtools-extensions/src/astUtils.js b/packages/react-devtools-extensions/src/astUtils.js index cc976bbd198a3..dff938c9d3817 100644 --- a/packages/react-devtools-extensions/src/astUtils.js +++ b/packages/react-devtools-extensions/src/astUtils.js @@ -20,14 +20,6 @@ export type SourceFileASTWithHookDetails = { source: string, }; -export type SourceMap = {| - mappings: string, - names: Array, - sources: Array, - sourcesContent: Array, - version: number, -|}; - const AST_NODE_TYPES = Object.freeze({ CALL_EXPRESSION: 'CallExpression', MEMBER_EXPRESSION: 'MemberExpression', diff --git a/packages/react-devtools-extensions/src/parseHookNames.js b/packages/react-devtools-extensions/src/parseHookNames.js index 3795ffe915b0f..9a56d656e1e72 100644 --- a/packages/react-devtools-extensions/src/parseHookNames.js +++ b/packages/react-devtools-extensions/src/parseHookNames.js @@ -24,7 +24,7 @@ import type { } from 'react-debug-tools/src/ReactDebugHooks'; import type {HookNames, LRUCache} from 'react-devtools-shared/src/types'; import type {Thenable} from 'shared/ReactTypes'; -import type {SourceConsumer, SourceMap} from './astUtils'; +import type {SourceConsumer} from './astUtils'; const SOURCE_MAP_REGEX = / ?sourceMappingURL=([^\s'"]+)/gm; const ABSOLUTE_URL_REGEX = /^https?:\/\//i; @@ -36,6 +36,12 @@ type HookSourceData = {| // Generated by react-debug-tools. hookSource: HookSource, + // Same as hookSource.fileName but guaranteed to be non-null. + runtimeSourceURL: string, + + // Original source URL if there is a source map, or the same as runtimeSourceURL. + originalSourceURL: string | null, + // AST for original source code; typically comes from a consumed source map. originalSourceAST: AST | null, @@ -52,26 +58,29 @@ type HookSourceData = {| // External URL of source map. // Sources without source maps (or with inline source maps) won't have this. sourceMapURL: string | null, +|}; - // Parsed source map object. - sourceMapContents: SourceMap | null, +type CachedRuntimeCodeMetadata = {| + sourceConsumer: SourceConsumer | null, |}; -type CachedMetadata = {| +type CachedSourceCodeMetadata = {| originalSourceAST: AST, originalSourceCode: string, - sourceConsumer: SourceConsumer | null, |}; // On large trees, encoding takes significant time. // Try to reuse the already encoded strings. -const fileNameToMetadataCache: LRUCache = new LRU({ +const runtimeURLToMetadataCache: LRUCache< + string, + CachedRuntimeCodeMetadata, +> = new LRU({ max: 50, - dispose: (fileName: string, metadata: CachedMetadata) => { + dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => { if (__DEBUG__) { console.log( - 'fileNameToHookSourceData.dispose() Evicting cached metadata for "' + - fileName + + 'runtimeURLToMetadataCache.dispose() Evicting cached metadata for "' + + runtimeSourceURL + '"', ); } @@ -83,6 +92,33 @@ const fileNameToMetadataCache: LRUCache = new LRU({ }, }); +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 + + '"', + ); + } + }, +}); + +function getLocationKey({ + fileName, + lineNumber, + columnNumber, +}: HookSource): string { + if (fileName == null || lineNumber == null || columnNumber == null) { + throw Error('Hook source code location not found.'); + } + return `${fileName}:${lineNumber}:${columnNumber}`; +} + export default async function parseHookNames( hooksTree: HooksTree, ): Thenable { @@ -97,8 +133,8 @@ export default async function parseHookNames( console.log('parseHookNames() hooksList:', hooksList); } - // Gather the unique set of source files to load for the built-in hooks. - const fileNameToHookSourceData: Map = new Map(); + // Gather the unique set of source locations to inspect for the built-in hooks. + const locationKeyToHookSourceData: Map = new Map(); for (let i = 0; i < hooksList.length; i++) { const hook = hooksList[i]; @@ -109,50 +145,52 @@ export default async function parseHookNames( throw Error('Hook source code location not found.'); } - const fileName = hookSource.fileName; - if (fileName == null) { - throw Error('Hook source code location not found.'); - } else { - if (!fileNameToHookSourceData.has(fileName)) { - const hookSourceData: HookSourceData = { - hookSource, - originalSourceAST: null, - originalSourceCode: null, - runtimeSourceCode: null, - sourceConsumer: null, - sourceMapURL: null, - sourceMapContents: null, - }; - - // If we've already loaded source/source map info for this file, - // we can skip reloading it (and more importantly, re-parsing it). - const metadata = fileNameToMetadataCache.get(fileName); - if (metadata != null) { - if (__DEBUG__) { - console.groupCollapsed( - 'parseHookNames() Found cached metadata for file "' + - fileName + - '"', - ); - console.log(metadata); - console.groupEnd(); - } + const runtimeSourceURL = hookSource.fileName; - hookSourceData.originalSourceAST = metadata.originalSourceAST; - hookSourceData.originalSourceCode = metadata.originalSourceCode; - hookSourceData.sourceConsumer = metadata.sourceConsumer; + const locationKey = getLocationKey(hookSource); + if (!locationKeyToHookSourceData.has(locationKey)) { + if (runtimeSourceURL == null) { + // Not reachable because getLocationKey will have thrown + throw Error('Hook source code location not found.'); + } + const hookSourceData: HookSourceData = { + hookSource, + runtimeSourceURL, + originalSourceAST: null, + originalSourceCode: null, + runtimeSourceCode: null, + sourceConsumer: null, + sourceMapURL: null, + originalSourceURL: 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(); } - - fileNameToHookSourceData.set(fileName, hookSourceData); + hookSourceData.sourceConsumer = runtimeMetadata.sourceConsumer; } + + locationKeyToHookSourceData.set(locationKey, hookSourceData); } } - return loadSourceFiles(fileNameToHookSourceData) - .then(() => extractAndLoadSourceMaps(fileNameToHookSourceData)) - .then(() => parseSourceAST(fileNameToHookSourceData)) - .then(() => updateLruCache(fileNameToHookSourceData)) - .then(() => findHookNames(hooksList, fileNameToHookSourceData)); + return loadSourceFiles(locationKeyToHookSourceData) + .then(() => extractAndLoadSourceMaps(locationKeyToHookSourceData)) + .then(() => parseSourceAST(locationKeyToHookSourceData)) + .then(() => updateLruCache(locationKeyToHookSourceData)) + .then(() => findHookNames(hooksList, locationKeyToHookSourceData)); } function decodeBase64String(encoded: string): Object { @@ -170,12 +208,30 @@ function decodeBase64String(encoded: string): Object { } function extractAndLoadSourceMaps( - fileNameToHookSourceData: Map, + locationKeyToHookSourceData: Map, ): Promise<*> { - const promises = []; - fileNameToHookSourceData.forEach(hookSourceData => { - if (hookSourceData.originalSourceAST !== null) { - // Use cached metadata. + // 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 ...', + ); + } + + // $FlowFixMe + const wasmMappingsURL = chrome.extension.getURL('mappings.wasm'); + + SourceMapConsumer.initialize({'lib/mappings.wasm': wasmMappingsURL}); + } + + // 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) { + // Use cached source map consumer. return; } @@ -189,10 +245,14 @@ function extractAndLoadSourceMaps( } } else { for (let i = 0; i < sourceMappingURLs.length; i++) { - const fileName = ((hookSourceData.hookSource.fileName: any): string); + const {runtimeSourceURL} = hookSourceData; const sourceMappingURL = sourceMappingURLs[i]; const index = sourceMappingURL.indexOf('base64,'); if (index >= 0) { + // TODO 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. @@ -214,10 +274,15 @@ function extractAndLoadSourceMaps( // Parsed source map might be a partial path like "src/App.js" const match = parsed.sources.find( source => - source === 'Inline Babel script' || fileName.includes(source), + source === 'Inline Babel script' || + runtimeSourceURL.includes(source), ); if (match) { - hookSourceData.sourceMapContents = parsed; + setPromises.push( + new SourceMapConsumer(parsed).then(sourceConsumer => { + hookSourceData.sourceConsumer = sourceConsumer; + }), + ); break; } } else { @@ -237,24 +302,36 @@ function extractAndLoadSourceMaps( } } else if (!url.startsWith('/')) { // Resolve paths relative to the location of the file name - const lastSlashIdx = fileName.lastIndexOf('/'); + const lastSlashIdx = runtimeSourceURL.lastIndexOf('/'); if (lastSlashIdx !== -1) { - const baseURL = fileName.slice(0, fileName.lastIndexOf('/')); + const baseURL = runtimeSourceURL.slice( + 0, + runtimeSourceURL.lastIndexOf('/'), + ); url = `${baseURL}/${url}`; } } hookSourceData.sourceMapURL = url; - if (__DEBUG__) { - console.log( - 'extractAndLoadSourceMaps() External source map "' + url + '"', + const fetchPromise = + fetchPromises.get(url) || + fetchFile(url).then( + sourceMapContents => + new SourceMapConsumer(JSON.parse(sourceMapContents)), ); + if (__DEBUG__) { + if (!fetchPromises.has(url)) { + console.log( + 'extractAndLoadSourceMaps() External source map "' + url + '"', + ); + } } - promises.push( - fetchFile(url).then(sourceMapContents => { - hookSourceData.sourceMapContents = JSON.parse(sourceMapContents); + fetchPromises.set(url, fetchPromise); + setPromises.push( + fetchPromise.then(sourceConsumer => { + hookSourceData.sourceConsumer = sourceConsumer; }), ); break; @@ -262,7 +339,7 @@ function extractAndLoadSourceMaps( } } }); - return Promise.all(promises); + return Promise.all(setPromises); } function fetchFile(url: string): Promise { @@ -286,7 +363,7 @@ function fetchFile(url: string): Promise { function findHookNames( hooksList: Array, - fileNameToHookSourceData: Map, + locationKeyToHookSourceData: Map, ): HookNames { const map: HookNames = new Map(); @@ -307,7 +384,8 @@ function findHookNames( return null; // Should not be reachable. } - const hookSourceData = fileNameToHookSourceData.get(fileName); + const locationKey = getLocationKey(hookSource); + const hookSourceData = locationKeyToHookSourceData.get(locationKey); if (!hookSourceData) { return null; // Should not be reachable. } @@ -328,7 +406,7 @@ function findHookNames( } else { originalSourceLineNumber = sourceConsumer.originalPositionFor({ line: lineNumber, - column: columnNumber, + column: columnNumber - 1, }).line; } @@ -373,103 +451,134 @@ function isValidUrl(possibleURL: string): boolean { } function loadSourceFiles( - fileNameToHookSourceData: Map, + locationKeyToHookSourceData: Map, ): Promise<*> { - const promises = []; - fileNameToHookSourceData.forEach((hookSourceData, fileName) => { - promises.push( - fetchFile(fileName).then(runtimeSourceCode => { + // 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() fileName "' + fileName + '"', + '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(promises); + return Promise.all(setPromises); } async function parseSourceAST( - fileNameToHookSourceData: Map, + 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('parseSourceAST() Initializing source-map library ...'); - } - - // $FlowFixMe - const wasmMappingsURL = chrome.extension.getURL('mappings.wasm'); - - SourceMapConsumer.initialize({'lib/mappings.wasm': wasmMappingsURL}); - } - - const promises = []; - fileNameToHookSourceData.forEach(hookSourceData => { + locationKeyToHookSourceData.forEach(hookSourceData => { if (hookSourceData.originalSourceAST !== null) { // Use cached metadata. return; } - const {runtimeSourceCode, sourceMapContents} = hookSourceData; - if (sourceMapContents !== null) { + const {sourceConsumer} = hookSourceData; + const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string); + let originalSourceURL, originalSourceCode; + if (sourceConsumer !== null) { // Parse and extract the AST from the source map. - promises.push( - SourceMapConsumer.with( - sourceMapContents, - null, - (sourceConsumer: SourceConsumer) => { - hookSourceData.sourceConsumer = sourceConsumer; - - // Now that the source map has been loaded, - // extract the original source for later. - const source = sourceMapContents.sources[0]; - const originalSourceCode = sourceConsumer.sourceContentFor( - source, - true, - ); + 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} = sourceConsumer.originalPositionFor({ + line: lineNumber, + column: columnNumber - 1, + }); - if (__DEBUG__) { - console.groupCollapsed( - 'parseSourceAST() Extracted source code from source map', - ); - console.log(originalSourceCode); - console.groupEnd(); - } + if (source == null) { + throw new Error( + 'Could not map hook runtime location to original source location', + ); + // TODO maybe fall back to the runtime source instead of throwing? + } - hookSourceData.originalSourceCode = originalSourceCode; + // TODO 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 = (sourceConsumer.sourceContentFor( + source, + true, + ): string); - // TODO Parsing should ideally be done off of the main thread. - hookSourceData.originalSourceAST = parse(originalSourceCode, { - sourceType: 'unambiguous', - plugins: ['jsx', 'typescript'], - }); - }, - ), - ); + if (__DEBUG__) { + console.groupCollapsed( + 'parseSourceAST() Extracted source code from source map', + ); + console.log(originalSourceCode); + console.groupEnd(); + } } else { // There's no source map to parse here so we can just parse the original source itself. - hookSourceData.originalSourceCode = runtimeSourceCode; + originalSourceCode = runtimeSourceCode; + // This mixes runtimeSourceURLs with source mapped URLs in the same cache + // key space. TODO: namespace them? + originalSourceURL = hookSourceData.runtimeSourceURL; + } + + hookSourceData.originalSourceCode = originalSourceCode; + hookSourceData.originalSourceURL = originalSourceURL; + // 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 { // TODO Parsing should ideally be done off of the main thread. - hookSourceData.originalSourceAST = parse(runtimeSourceCode, { + const originalSourceAST = parse(originalSourceCode, { sourceType: 'unambiguous', plugins: ['jsx', 'typescript'], }); + hookSourceData.originalSourceAST = originalSourceAST; + if (__DEBUG__) { + console.log( + 'parseSourceAST() Caching source metadata for "' + + originalSourceURL + + '"', + ); + } + originalURLToMetadataCache.set(originalSourceURL, { + originalSourceAST, + originalSourceCode, + }); } }); - return Promise.all(promises); + return Promise.resolve(); } function flattenHooksList( @@ -486,23 +595,23 @@ function flattenHooksList( } function updateLruCache( - fileNameToHookSourceData: Map, + locationKeyToHookSourceData: Map, ): Promise<*> { - fileNameToHookSourceData.forEach( - ({originalSourceAST, originalSourceCode, sourceConsumer}, fileName) => { - // Only set once to avoid triggering eviction/cleanup code. - if (!fileNameToMetadataCache.has(fileName)) { - if (__DEBUG__) { - console.log('updateLruCache() Caching metada for "' + fileName + '"'); - } - - fileNameToMetadataCache.set(fileName, { - originalSourceAST, - originalSourceCode: ((originalSourceCode: any): string), - sourceConsumer, - }); + locationKeyToHookSourceData.forEach(({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, { + sourceConsumer, + }); + } + }); return Promise.resolve(); }