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';
const FETCH_OPTIONS = {cache: 'force-cache'};
const MAX_SOURCE_LENGTH = 100_000_000;
export type HookSourceAndMetadata = {
hookSource: HookSource,
runtimeSourceCode: string | null,
runtimeSourceURL: string,
sourceMapJSON: MixedSourceMap | null,
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),
);
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>>>> {
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);
let sourceMappingURLMatch = withSyncPerfMeasurements(
'sourceMapRegex.exec(runtimeSourceCode)',
() => sourceMapRegex.exec(runtimeSourceCode),
);
if (sourceMappingURLMatch == null) {
if (__DEBUG__) {
console.log('extractAndLoadSourceMapJSON() No source map found');
}
} else {
const externalSourceMapURLs = [];
while (sourceMappingURLMatch != null) {
const {runtimeSourceURL} = hookSourceAndMetadata;
const sourceMappingURL = sourceMappingURLMatch[1];
const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0;
if (hasInlineSourceMap) {
try {
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();
}
if (sourceMapIncludesSource(sourceMapJSON, runtimeSourceURL)) {
hookSourceAndMetadata.sourceMapJSON = sourceMapJSON;
hookSourceAndMetadata.runtimeSourceCode = null;
break;
}
} catch (error) {
}
} else {
externalSourceMapURLs.push(sourceMappingURL);
}
sourceMappingURLMatch = withSyncPerfMeasurements(
'sourceMapRegex.exec(runtimeSourceCode)',
() => sourceMapRegex.exec(runtimeSourceCode),
);
}
if (hookSourceAndMetadata.sourceMapJSON === null) {
externalSourceMapURLs.forEach((sourceMappingURL, index) => {
if (index !== externalSourceMapURLs.length - 1) {
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('/')) {
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;
},
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;
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)) {
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 {
const locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata =
new Map();
for (let i = 0; i < hooksList.length; i++) {
const hook = hooksList[i];
const hookSource = hook.hookSource;
if (hookSource == null) {
throw Error('Hook source code location not found.');
}
const locationKey = getHookSourceLocationKey(hookSource);
if (!locationKeyToHookSourceAndMetadata.has(locationKey)) {
const runtimeSourceURL = ((hookSource.fileName: any): string);
const hookSourceAndMetadata: HookSourceAndMetadata = {
hookSource,
runtimeSourceCode: null,
runtimeSourceURL,
sourceMapJSON: null,
sourceMapURL: null,
};
locationKeyToHookSourceAndMetadata.set(
locationKey,
hookSourceAndMetadata,
);
}
}
return locationKeyToHookSourceAndMetadata;
}
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>>>> {
const dedupedFetchPromises = new Map<string, Promise<$FlowFixMe>>();
const setterPromises = [];
locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => {
const {runtimeSourceURL} = hookSourceAndMetadata;
let fetchFileFunction = fetchFile;
if (fetchFileWithCaching != null) {
fetchFileFunction = url => {
return withAsyncPerfMeasurements(
`fetchFileWithCaching("${url}")`,
() => {
return ((fetchFileWithCaching: any): FetchFileWithCaching)(url);
},
);
};
}
const fetchPromise =
dedupedFetchPromises.get(runtimeSourceURL) ||
fetchFileFunction(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;
});
dedupedFetchPromises.set(runtimeSourceURL, fetchPromise);
setterPromises.push(
fetchPromise.then(runtimeSourceCode => {
hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode;
}),
);
});
return Promise.all(setterPromises);
}