mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
## Summary - Updated `webpack` (and all related packages) to v5 in `react-devtools-*` packages. - I haven't touched any `TODO (Webpack 5)`. Tried to poke it, but each my attempt failed and parsing hook names feature stopped working. I will work on this in a separate PR. - This work is one of prerequisites for updating Firefox extension to manifests v3 related PRs: https://github.com/facebook/react/pull/22267 https://github.com/facebook/react/pull/26506 ## How did you test this change? Tested on all surfaces, explicitly checked that parsing hook names feature still works.
516 lines
18 KiB
JavaScript
516 lines
18 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
// Parsing source and source maps is done in a Web Worker
|
|
// because parsing is CPU intensive and should not block the UI thread.
|
|
//
|
|
// Fetching source and source map files is intentionally done on the UI thread
|
|
// so that loaded source files can reuse the browser's Network cache.
|
|
// Requests made from within an extension do not share the page's Network cache,
|
|
// but messages can be sent from the UI thread to the content script
|
|
// which can make a request from the page's context (with caching).
|
|
//
|
|
// Some overhead may be incurred sharing (serializing) the loaded data between contexts,
|
|
// but less than fetching the file to begin with,
|
|
// and in some cases we can avoid serializing the source code at all
|
|
// (e.g. when we are in an environment that supports our custom metadata format).
|
|
//
|
|
// The overall flow of this file is such:
|
|
// 1. Find the Set of source files defining the hooks and load them all.
|
|
// Then for each source file, do the following:
|
|
//
|
|
// a. Search loaded source file to see if a source map is available.
|
|
// If so, load that file and pass it to a Worker for parsing.
|
|
// The source map is used to retrieve the original source,
|
|
// which is then also parsed in the Worker to infer hook names.
|
|
// This is less ideal because parsing a full source map is slower,
|
|
// since we need to evaluate the mappings in order to map the runtime code to the original source,
|
|
// but at least the eventual source that we parse to an AST is small/fast.
|
|
//
|
|
// b. If no source map, pass the full source to a Worker for parsing.
|
|
// Use the source to infer hook names.
|
|
// This is the least optimal route as parsing the full source is very CPU intensive.
|
|
//
|
|
// In the future, we may add an additional optimization the above sequence.
|
|
// This check would come before the source map check:
|
|
//
|
|
// a. Search loaded source file to see if a custom React metadata file is available.
|
|
// If so, load that file and pass it to a Worker for parsing and extracting.
|
|
// This is the fastest option since our custom metadata file is much smaller than a full source map,
|
|
// and there is no need to convert runtime code to the original source.
|
|
|
|
import {__DEBUG__} from 'react-devtools-shared/src/constants';
|
|
import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
|
|
import {sourceMapIncludesSource} from '../SourceMapUtils';
|
|
import {
|
|
withAsyncPerfMeasurements,
|
|
withCallbackPerfMeasurements,
|
|
withSyncPerfMeasurements,
|
|
} from 'react-devtools-shared/src/PerformanceLoggingUtils';
|
|
|
|
import type {
|
|
HooksNode,
|
|
HookSource,
|
|
HooksTree,
|
|
} from 'react-debug-tools/src/ReactDebugHooks';
|
|
import type {MixedSourceMap} from '../SourceMapTypes';
|
|
import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext';
|
|
|
|
// Prefer a cached albeit stale response to reduce download time.
|
|
// We wouldn't want to load/parse a newer version of the source (even if one existed).
|
|
const FETCH_OPTIONS = {cache: 'force-cache'};
|
|
|
|
const MAX_SOURCE_LENGTH = 100_000_000;
|
|
|
|
export type HookSourceAndMetadata = {
|
|
// Generated by react-debug-tools.
|
|
hookSource: HookSource,
|
|
|
|
// Compiled code (React components or custom hooks) containing primitive hook calls.
|
|
runtimeSourceCode: string | null,
|
|
|
|
// Same as hookSource.fileName but guaranteed to be non-null.
|
|
runtimeSourceURL: string,
|
|
|
|
// Raw source map JSON.
|
|
// Either decoded from an inline source map or loaded from an externa source map file.
|
|
// Sources without source maps won't have this.
|
|
sourceMapJSON: MixedSourceMap | null,
|
|
|
|
// External URL of source map.
|
|
// Sources without source maps (or with inline source maps) won't have this.
|
|
sourceMapURL: string | null,
|
|
};
|
|
|
|
export type LocationKeyToHookSourceAndMetadata = Map<
|
|
string,
|
|
HookSourceAndMetadata,
|
|
>;
|
|
export type HooksList = Array<HooksNode>;
|
|
|
|
export async function loadSourceAndMetadata(
|
|
hooksList: HooksList,
|
|
fetchFileWithCaching: FetchFileWithCaching | null,
|
|
): Promise<LocationKeyToHookSourceAndMetadata> {
|
|
return withAsyncPerfMeasurements('loadSourceAndMetadata()', async () => {
|
|
const locationKeyToHookSourceAndMetadata = withSyncPerfMeasurements(
|
|
'initializeHookSourceAndMetadata',
|
|
() => initializeHookSourceAndMetadata(hooksList),
|
|
);
|
|
|
|
await withAsyncPerfMeasurements('loadSourceFiles()', () =>
|
|
loadSourceFiles(locationKeyToHookSourceAndMetadata, fetchFileWithCaching),
|
|
);
|
|
|
|
await withAsyncPerfMeasurements('extractAndLoadSourceMapJSON()', () =>
|
|
extractAndLoadSourceMapJSON(locationKeyToHookSourceAndMetadata),
|
|
);
|
|
|
|
// At this point, we've loaded JS source (text) and source map (JSON).
|
|
// The remaining works (parsing these) is CPU intensive and should be done in a worker.
|
|
return locationKeyToHookSourceAndMetadata;
|
|
});
|
|
}
|
|
|
|
function decodeBase64String(encoded: string): Object {
|
|
if (typeof atob === 'function') {
|
|
return atob(encoded);
|
|
} else if (
|
|
typeof Buffer !== 'undefined' &&
|
|
Buffer !== null &&
|
|
typeof Buffer.from === 'function'
|
|
) {
|
|
return Buffer.from(encoded, 'base64');
|
|
} else {
|
|
throw Error('Cannot decode base64 string');
|
|
}
|
|
}
|
|
|
|
function extractAndLoadSourceMapJSON(
|
|
locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
|
|
): Promise<Array<$Call<<T>(p: Promise<T> | T) => T, Promise<void>>>> {
|
|
// Deduplicate fetches, since there can be multiple location keys per source map.
|
|
const dedupedFetchPromises = new Map<string, Promise<$FlowFixMe>>();
|
|
|
|
if (__DEBUG__) {
|
|
console.log(
|
|
'extractAndLoadSourceMapJSON() load',
|
|
locationKeyToHookSourceAndMetadata.size,
|
|
'source maps',
|
|
);
|
|
}
|
|
|
|
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 = withSyncPerfMeasurements(
|
|
'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) {
|
|
try {
|
|
// TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch)
|
|
// since there can be multiple location keys per source map.
|
|
|
|
// Web apps like Code Sandbox embed multiple inline source maps.
|
|
// In this case, we need to loop through and find the right one.
|
|
// We may also need to trim any part of this string that isn't based64 encoded data.
|
|
const trimmed = ((sourceMappingURL.match(
|
|
/base64,([a-zA-Z0-9+\/=]+)/,
|
|
): any): Array<string>)[1];
|
|
const decoded = withSyncPerfMeasurements(
|
|
'decodeBase64String()',
|
|
() => decodeBase64String(trimmed),
|
|
);
|
|
|
|
const sourceMapJSON = withSyncPerfMeasurements(
|
|
'JSON.parse(decoded)',
|
|
() => JSON.parse(decoded),
|
|
);
|
|
|
|
if (__DEBUG__) {
|
|
console.groupCollapsed(
|
|
'extractAndLoadSourceMapJSON() Inline source map',
|
|
);
|
|
console.log(sourceMapJSON);
|
|
console.groupEnd();
|
|
}
|
|
|
|
// Hook source might be a URL like "https://4syus.csb.app/src/App.js"
|
|
// Parsed source map might be a partial path like "src/App.js"
|
|
if (sourceMapIncludesSource(sourceMapJSON, runtimeSourceURL)) {
|
|
hookSourceAndMetadata.sourceMapJSON = sourceMapJSON;
|
|
|
|
// OPTIMIZATION If we've located a source map for this source,
|
|
// we'll use it to retrieve the original source (to extract hook names).
|
|
// We only fall back to parsing the full source code is when there's no source map.
|
|
// The source is (potentially) very large,
|
|
// So we can avoid the overhead of serializing it unnecessarily.
|
|
hookSourceAndMetadata.runtimeSourceCode = null;
|
|
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
// We've likely encountered a string in the source code that looks like a source map but isn't.
|
|
// Maybe the source code contains a "sourceMappingURL" comment or soething similar.
|
|
// In either case, let's skip this and keep looking.
|
|
}
|
|
} else {
|
|
externalSourceMapURLs.push(sourceMappingURL);
|
|
}
|
|
|
|
// If the first source map we found wasn't a match, check for more.
|
|
sourceMappingURLMatch = withSyncPerfMeasurements(
|
|
'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 = withSyncPerfMeasurements(
|
|
'JSON.parse(sourceMapContents)',
|
|
() => JSON.parse(sourceMapContents),
|
|
);
|
|
|
|
return sourceMapJSON;
|
|
},
|
|
|
|
// In this case, we fall back to the assumption that the source has no source map.
|
|
// This might indicate an (unlikely) edge case that had no source map,
|
|
// but contained the string "sourceMappingURL".
|
|
error => null,
|
|
);
|
|
|
|
if (__DEBUG__) {
|
|
if (!dedupedFetchPromises.has(url)) {
|
|
console.log(
|
|
`extractAndLoadSourceMapJSON() External source map "${url}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
dedupedFetchPromises.set(url, fetchPromise);
|
|
|
|
setterPromises.push(
|
|
fetchPromise.then(sourceMapJSON => {
|
|
if (sourceMapJSON !== null) {
|
|
hookSourceAndMetadata.sourceMapJSON = sourceMapJSON;
|
|
|
|
// OPTIMIZATION If we've located a source map for this source,
|
|
// we'll use it to retrieve the original source (to extract hook names).
|
|
// We only fall back to parsing the full source code is when there's no source map.
|
|
// The source is (potentially) very large,
|
|
// So we can avoid the overhead of serializing it unnecessarily.
|
|
hookSourceAndMetadata.runtimeSourceCode = null;
|
|
}
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return Promise.all(setterPromises);
|
|
}
|
|
|
|
function fetchFile(
|
|
url: string,
|
|
markName: string = 'fetchFile',
|
|
): Promise<string> {
|
|
return withCallbackPerfMeasurements(`${markName}("${url}")`, done => {
|
|
return new Promise((resolve, reject) => {
|
|
fetch(url, FETCH_OPTIONS).then(
|
|
response => {
|
|
if (response.ok) {
|
|
response
|
|
.text()
|
|
.then(text => {
|
|
done();
|
|
resolve(text);
|
|
})
|
|
.catch(error => {
|
|
if (__DEBUG__) {
|
|
console.log(
|
|
`${markName}() Could not read text for url "${url}"`,
|
|
);
|
|
}
|
|
done();
|
|
reject(null);
|
|
});
|
|
} else {
|
|
if (__DEBUG__) {
|
|
console.log(`${markName}() Got bad response for url "${url}"`);
|
|
}
|
|
done();
|
|
reject(null);
|
|
}
|
|
},
|
|
error => {
|
|
if (__DEBUG__) {
|
|
console.log(`${markName}() Could not fetch file: ${error.message}`);
|
|
}
|
|
done();
|
|
reject(null);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
export function hasNamedHooks(hooksTree: HooksTree): boolean {
|
|
for (let i = 0; i < hooksTree.length; i++) {
|
|
const hook = hooksTree[i];
|
|
|
|
if (!isUnnamedBuiltInHook(hook)) {
|
|
return true;
|
|
}
|
|
|
|
if (hook.subHooks.length > 0) {
|
|
if (hasNamedHooks(hook.subHooks)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function flattenHooksList(hooksTree: HooksTree): HooksList {
|
|
const hooksList: HooksList = [];
|
|
withSyncPerfMeasurements('flattenHooksList()', () => {
|
|
flattenHooksListImpl(hooksTree, hooksList);
|
|
});
|
|
|
|
if (__DEBUG__) {
|
|
console.log('flattenHooksList() hooksList:', hooksList);
|
|
}
|
|
|
|
return hooksList;
|
|
}
|
|
|
|
function flattenHooksListImpl(
|
|
hooksTree: HooksTree,
|
|
hooksList: Array<HooksNode>,
|
|
): void {
|
|
for (let i = 0; i < hooksTree.length; i++) {
|
|
const hook = hooksTree[i];
|
|
|
|
if (isUnnamedBuiltInHook(hook)) {
|
|
// No need to load source code or do any parsing for unnamed hooks.
|
|
if (__DEBUG__) {
|
|
console.log('flattenHooksListImpl() Skipping unnamed hook', hook);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
hooksList.push(hook);
|
|
|
|
if (hook.subHooks.length > 0) {
|
|
flattenHooksListImpl(hook.subHooks, hooksList);
|
|
}
|
|
}
|
|
}
|
|
|
|
function initializeHookSourceAndMetadata(
|
|
hooksList: Array<HooksNode>,
|
|
): LocationKeyToHookSourceAndMetadata {
|
|
// Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
|
|
const locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata =
|
|
new Map();
|
|
for (let i = 0; i < hooksList.length; i++) {
|
|
const hook = hooksList[i];
|
|
|
|
const hookSource = hook.hookSource;
|
|
if (hookSource == null) {
|
|
// Older versions of react-debug-tools don't include this information.
|
|
// In this case, we can't continue.
|
|
throw Error('Hook source code location not found.');
|
|
}
|
|
|
|
const locationKey = getHookSourceLocationKey(hookSource);
|
|
if (!locationKeyToHookSourceAndMetadata.has(locationKey)) {
|
|
// Can't be null because getHookSourceLocationKey() would have thrown
|
|
const runtimeSourceURL = ((hookSource.fileName: any): string);
|
|
|
|
const hookSourceAndMetadata: HookSourceAndMetadata = {
|
|
hookSource,
|
|
runtimeSourceCode: null,
|
|
runtimeSourceURL,
|
|
sourceMapJSON: null,
|
|
sourceMapURL: null,
|
|
};
|
|
|
|
locationKeyToHookSourceAndMetadata.set(
|
|
locationKey,
|
|
hookSourceAndMetadata,
|
|
);
|
|
}
|
|
}
|
|
|
|
return locationKeyToHookSourceAndMetadata;
|
|
}
|
|
|
|
// Determines whether incoming hook is a primitive hook that gets assigned to variables.
|
|
function isUnnamedBuiltInHook(hook: HooksNode) {
|
|
return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes(
|
|
hook.name,
|
|
);
|
|
}
|
|
|
|
function loadSourceFiles(
|
|
locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
|
|
fetchFileWithCaching: FetchFileWithCaching | null,
|
|
): Promise<Array<$Call<<T>(p: Promise<T> | T) => T, Promise<void>>>> {
|
|
// Deduplicate fetches, since there can be multiple location keys per file.
|
|
const dedupedFetchPromises = new Map<string, Promise<$FlowFixMe>>();
|
|
|
|
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 withAsyncPerfMeasurements(
|
|
`fetchFileWithCaching("${url}")`,
|
|
() => {
|
|
return ((fetchFileWithCaching: any): FetchFileWithCaching)(url);
|
|
},
|
|
);
|
|
};
|
|
}
|
|
|
|
const fetchPromise =
|
|
dedupedFetchPromises.get(runtimeSourceURL) ||
|
|
fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => {
|
|
// TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps,
|
|
// because then we need to parse the full source file as an AST.
|
|
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
|
|
throw Error('Source code too large to parse');
|
|
}
|
|
|
|
if (__DEBUG__) {
|
|
console.groupCollapsed(
|
|
`loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
|
|
);
|
|
console.log(runtimeSourceCode);
|
|
console.groupEnd();
|
|
}
|
|
|
|
return runtimeSourceCode;
|
|
});
|
|
dedupedFetchPromises.set(runtimeSourceURL, fetchPromise);
|
|
|
|
setterPromises.push(
|
|
fetchPromise.then(runtimeSourceCode => {
|
|
hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode;
|
|
}),
|
|
);
|
|
});
|
|
|
|
return Promise.all(setterPromises);
|
|
}
|