1. /**
    
  2.  * Copyright (c) Meta Platforms, Inc. and affiliates.
    
  3.  *
    
  4.  * This source code is licensed under the MIT license found in the
    
  5.  * LICENSE file in the root directory of this source tree.
    
  6.  *
    
  7.  * @flow
    
  8.  */
    
  9. 
    
  10. // For an overview of why the code in this file is structured this way,
    
  11. // refer to header comments in loadSourceAndMetadata.
    
  12. 
    
  13. import {parse} from '@babel/parser';
    
  14. import LRU from 'lru-cache';
    
  15. import {getHookName} from '../astUtils';
    
  16. import {areSourceMapsAppliedToErrors} from '../ErrorTester';
    
  17. import {__DEBUG__} from 'react-devtools-shared/src/constants';
    
  18. import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
    
  19. import {SourceMapMetadataConsumer} from '../SourceMapMetadataConsumer';
    
  20. import {
    
  21.   withAsyncPerfMeasurements,
    
  22.   withSyncPerfMeasurements,
    
  23. } from 'react-devtools-shared/src/PerformanceLoggingUtils';
    
  24. import SourceMapConsumer from '../SourceMapConsumer';
    
  25. 
    
  26. import type {SourceMapConsumerType} from '../SourceMapConsumer';
    
  27. import type {
    
  28.   HooksList,
    
  29.   LocationKeyToHookSourceAndMetadata,
    
  30. } from './loadSourceAndMetadata';
    
  31. import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks';
    
  32. import type {
    
  33.   HookNames,
    
  34.   LRUCache,
    
  35. } from 'react-devtools-shared/src/frontend/types';
    
  36. 
    
  37. type AST = mixed;
    
  38. 
    
  39. type HookParsedMetadata = {
    
  40.   // API for consuming metadfata present in extended source map.
    
  41.   metadataConsumer: SourceMapMetadataConsumer | null,
    
  42. 
    
  43.   // AST for original source code; typically comes from a consumed source map.
    
  44.   originalSourceAST: AST | null,
    
  45. 
    
  46.   // Source code (React components or custom hooks) containing primitive hook calls.
    
  47.   // If no source map has been provided, this code will be the same as runtimeSourceCode.
    
  48.   originalSourceCode: string | null,
    
  49. 
    
  50.   // Original source URL if there is a source map, or the same as runtimeSourceURL.
    
  51.   originalSourceURL: string | null,
    
  52. 
    
  53.   // Line number in original source code.
    
  54.   originalSourceLineNumber: number | null,
    
  55. 
    
  56.   // Column number in original source code.
    
  57.   originalSourceColumnNumber: number | null,
    
  58. 
    
  59.   // Alternate APIs from source-map for parsing source maps (if detected).
    
  60.   sourceMapConsumer: SourceMapConsumerType | null,
    
  61. };
    
  62. 
    
  63. type LocationKeyToHookParsedMetadata = Map<string, HookParsedMetadata>;
    
  64. 
    
  65. type CachedRuntimeCodeMetadata = {
    
  66.   metadataConsumer: SourceMapMetadataConsumer | null,
    
  67.   sourceMapConsumer: SourceMapConsumerType | null,
    
  68. };
    
  69. 
    
  70. const runtimeURLToMetadataCache: LRUCache<string, CachedRuntimeCodeMetadata> =
    
  71.   new LRU({max: 50});
    
  72. 
    
  73. type CachedSourceCodeMetadata = {
    
  74.   originalSourceAST: AST,
    
  75.   originalSourceCode: string,
    
  76. };
    
  77. 
    
  78. const originalURLToMetadataCache: LRUCache<string, CachedSourceCodeMetadata> =
    
  79.   new LRU({
    
  80.     max: 50,
    
  81.     dispose: (
    
  82.       originalSourceURL: string,
    
  83.       metadata: CachedSourceCodeMetadata,
    
  84.     ) => {
    
  85.       if (__DEBUG__) {
    
  86.         console.log(
    
  87.           `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`,
    
  88.         );
    
  89.       }
    
  90.     },
    
  91.   });
    
  92. 
    
  93. export async function parseSourceAndMetadata(
    
  94.   hooksList: HooksList,
    
  95.   locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
    
  96. ): Promise<HookNames | null> {
    
  97.   return withAsyncPerfMeasurements('parseSourceAndMetadata()', async () => {
    
  98.     const locationKeyToHookParsedMetadata = withSyncPerfMeasurements(
    
  99.       'initializeHookParsedMetadata',
    
  100.       () => initializeHookParsedMetadata(locationKeyToHookSourceAndMetadata),
    
  101.     );
    
  102. 
    
  103.     withSyncPerfMeasurements('parseSourceMaps', () =>
    
  104.       parseSourceMaps(
    
  105.         locationKeyToHookSourceAndMetadata,
    
  106.         locationKeyToHookParsedMetadata,
    
  107.       ),
    
  108.     );
    
  109. 
    
  110.     withSyncPerfMeasurements('parseSourceAST()', () =>
    
  111.       parseSourceAST(
    
  112.         locationKeyToHookSourceAndMetadata,
    
  113.         locationKeyToHookParsedMetadata,
    
  114.       ),
    
  115.     );
    
  116. 
    
  117.     return withSyncPerfMeasurements('findHookNames()', () =>
    
  118.       findHookNames(hooksList, locationKeyToHookParsedMetadata),
    
  119.     );
    
  120.   });
    
  121. }
    
  122. 
    
  123. function findHookNames(
    
  124.   hooksList: HooksList,
    
  125.   locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
    
  126. ): HookNames {
    
  127.   const map: HookNames = new Map();
    
  128. 
    
  129.   hooksList.map(hook => {
    
  130.     // We already guard against a null HookSource in parseHookNames()
    
  131.     const hookSource = ((hook.hookSource: any): HookSource);
    
  132.     const fileName = hookSource.fileName;
    
  133.     if (!fileName) {
    
  134.       return null; // Should not be reachable.
    
  135.     }
    
  136. 
    
  137.     const locationKey = getHookSourceLocationKey(hookSource);
    
  138.     const hookParsedMetadata = locationKeyToHookParsedMetadata.get(locationKey);
    
  139.     if (!hookParsedMetadata) {
    
  140.       return null; // Should not be reachable.
    
  141.     }
    
  142. 
    
  143.     const {lineNumber, columnNumber} = hookSource;
    
  144.     if (!lineNumber || !columnNumber) {
    
  145.       return null; // Should not be reachable.
    
  146.     }
    
  147. 
    
  148.     const {
    
  149.       originalSourceURL,
    
  150.       originalSourceColumnNumber,
    
  151.       originalSourceLineNumber,
    
  152.     } = hookParsedMetadata;
    
  153. 
    
  154.     if (
    
  155.       originalSourceLineNumber == null ||
    
  156.       originalSourceColumnNumber == null ||
    
  157.       originalSourceURL == null
    
  158.     ) {
    
  159.       return null; // Should not be reachable.
    
  160.     }
    
  161. 
    
  162.     let name;
    
  163.     const {metadataConsumer} = hookParsedMetadata;
    
  164.     if (metadataConsumer != null) {
    
  165.       name = withSyncPerfMeasurements('metadataConsumer.hookNameFor()', () =>
    
  166.         metadataConsumer.hookNameFor({
    
  167.           line: originalSourceLineNumber,
    
  168.           column: originalSourceColumnNumber,
    
  169.           source: originalSourceURL,
    
  170.         }),
    
  171.       );
    
  172.     }
    
  173. 
    
  174.     if (name == null) {
    
  175.       name = withSyncPerfMeasurements('getHookName()', () =>
    
  176.         getHookName(
    
  177.           hook,
    
  178.           hookParsedMetadata.originalSourceAST,
    
  179.           ((hookParsedMetadata.originalSourceCode: any): string),
    
  180.           ((originalSourceLineNumber: any): number),
    
  181.           originalSourceColumnNumber,
    
  182.         ),
    
  183.       );
    
  184.     }
    
  185. 
    
  186.     if (__DEBUG__) {
    
  187.       console.log(`findHookNames() Found name "${name || '-'}"`);
    
  188.     }
    
  189. 
    
  190.     const key = getHookSourceLocationKey(hookSource);
    
  191.     map.set(key, name);
    
  192.   });
    
  193. 
    
  194.   return map;
    
  195. }
    
  196. 
    
  197. function initializeHookParsedMetadata(
    
  198.   locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
    
  199. ) {
    
  200.   // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
    
  201.   const locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata =
    
  202.     new Map();
    
  203.   locationKeyToHookSourceAndMetadata.forEach(
    
  204.     (hookSourceAndMetadata, locationKey) => {
    
  205.       const hookParsedMetadata: HookParsedMetadata = {
    
  206.         metadataConsumer: null,
    
  207.         originalSourceAST: null,
    
  208.         originalSourceCode: null,
    
  209.         originalSourceURL: null,
    
  210.         originalSourceLineNumber: null,
    
  211.         originalSourceColumnNumber: null,
    
  212.         sourceMapConsumer: null,
    
  213.       };
    
  214. 
    
  215.       locationKeyToHookParsedMetadata.set(locationKey, hookParsedMetadata);
    
  216.     },
    
  217.   );
    
  218. 
    
  219.   return locationKeyToHookParsedMetadata;
    
  220. }
    
  221. 
    
  222. function parseSourceAST(
    
  223.   locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
    
  224.   locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
    
  225. ): void {
    
  226.   locationKeyToHookSourceAndMetadata.forEach(
    
  227.     (hookSourceAndMetadata, locationKey) => {
    
  228.       const hookParsedMetadata =
    
  229.         locationKeyToHookParsedMetadata.get(locationKey);
    
  230.       if (hookParsedMetadata == null) {
    
  231.         throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`);
    
  232.       }
    
  233. 
    
  234.       if (hookParsedMetadata.originalSourceAST !== null) {
    
  235.         // Use cached metadata.
    
  236.         return;
    
  237.       }
    
  238. 
    
  239.       if (
    
  240.         hookParsedMetadata.originalSourceURL != null &&
    
  241.         hookParsedMetadata.originalSourceCode != null &&
    
  242.         hookParsedMetadata.originalSourceColumnNumber != null &&
    
  243.         hookParsedMetadata.originalSourceLineNumber != null
    
  244.       ) {
    
  245.         // Use cached metadata.
    
  246.         return;
    
  247.       }
    
  248. 
    
  249.       const {lineNumber, columnNumber} = hookSourceAndMetadata.hookSource;
    
  250.       if (lineNumber == null || columnNumber == null) {
    
  251.         throw Error('Hook source code location not found.');
    
  252.       }
    
  253. 
    
  254.       const {metadataConsumer, sourceMapConsumer} = hookParsedMetadata;
    
  255.       const runtimeSourceCode =
    
  256.         ((hookSourceAndMetadata.runtimeSourceCode: any): string);
    
  257.       let hasHookMap = false;
    
  258.       let originalSourceURL;
    
  259.       let originalSourceCode;
    
  260.       let originalSourceColumnNumber;
    
  261.       let originalSourceLineNumber;
    
  262.       if (areSourceMapsAppliedToErrors() || sourceMapConsumer === null) {
    
  263.         // Either the current environment automatically applies source maps to errors,
    
  264.         // or the current code had no source map to begin with.
    
  265.         // Either way, we don't need to convert the Error stack frame locations.
    
  266.         originalSourceColumnNumber = columnNumber;
    
  267.         originalSourceLineNumber = lineNumber;
    
  268.         // There's no source map to parse here so we can just parse the original source itself.
    
  269.         originalSourceCode = runtimeSourceCode;
    
  270.         // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space.
    
  271.         // Namespace them?
    
  272.         originalSourceURL = hookSourceAndMetadata.runtimeSourceURL;
    
  273.       } else {
    
  274.         const {column, line, sourceContent, sourceURL} =
    
  275.           sourceMapConsumer.originalPositionFor({
    
  276.             columnNumber,
    
  277.             lineNumber,
    
  278.           });
    
  279. 
    
  280.         originalSourceColumnNumber = column;
    
  281.         originalSourceLineNumber = line;
    
  282.         originalSourceCode = sourceContent;
    
  283.         originalSourceURL = sourceURL;
    
  284.       }
    
  285. 
    
  286.       hookParsedMetadata.originalSourceCode = originalSourceCode;
    
  287.       hookParsedMetadata.originalSourceURL = originalSourceURL;
    
  288.       hookParsedMetadata.originalSourceLineNumber = originalSourceLineNumber;
    
  289.       hookParsedMetadata.originalSourceColumnNumber =
    
  290.         originalSourceColumnNumber;
    
  291. 
    
  292.       if (
    
  293.         metadataConsumer != null &&
    
  294.         metadataConsumer.hasHookMap(originalSourceURL)
    
  295.       ) {
    
  296.         hasHookMap = true;
    
  297.       }
    
  298. 
    
  299.       if (__DEBUG__) {
    
  300.         console.log(
    
  301.           `parseSourceAST() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`,
    
  302.         );
    
  303.       }
    
  304. 
    
  305.       if (hasHookMap) {
    
  306.         if (__DEBUG__) {
    
  307.           console.log(
    
  308.             `parseSourceAST() Found hookMap and skipping parsing for "${originalSourceURL}"`,
    
  309.           );
    
  310.         }
    
  311.         // If there's a hook map present from an extended sourcemap then
    
  312.         // we don't need to parse the source files and instead can use the
    
  313.         // hook map to extract hook names.
    
  314.         return;
    
  315.       }
    
  316. 
    
  317.       if (__DEBUG__) {
    
  318.         console.log(
    
  319.           `parseSourceAST() Did not find hook map for "${originalSourceURL}"`,
    
  320.         );
    
  321.       }
    
  322. 
    
  323.       // The cache also serves to deduplicate parsing by URL in our loop over location keys.
    
  324.       // This may need to change if we switch to async parsing.
    
  325.       const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL);
    
  326.       if (sourceMetadata != null) {
    
  327.         if (__DEBUG__) {
    
  328.           console.groupCollapsed(
    
  329.             `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`,
    
  330.           );
    
  331.           console.log(sourceMetadata);
    
  332.           console.groupEnd();
    
  333.         }
    
  334.         hookParsedMetadata.originalSourceAST = sourceMetadata.originalSourceAST;
    
  335.         hookParsedMetadata.originalSourceCode =
    
  336.           sourceMetadata.originalSourceCode;
    
  337.       } else {
    
  338.         try {
    
  339.           // TypeScript is the most commonly used typed JS variant so let's default to it
    
  340.           // unless we detect explicit Flow usage via the "@flow" pragma.
    
  341.           const plugin =
    
  342.             originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript';
    
  343. 
    
  344.           // TODO (named hooks) This is probably where we should check max source length,
    
  345.           // rather than in loadSourceAndMetatada -> loadSourceFiles().
    
  346.           // TODO(#22319): Support source files that are html files with inline script tags.
    
  347.           const originalSourceAST = withSyncPerfMeasurements(
    
  348.             '[@babel/parser] parse(originalSourceCode)',
    
  349.             () =>
    
  350.               parse(originalSourceCode, {
    
  351.                 sourceType: 'unambiguous',
    
  352.                 plugins: ['jsx', plugin],
    
  353.               }),
    
  354.           );
    
  355.           hookParsedMetadata.originalSourceAST = originalSourceAST;
    
  356. 
    
  357.           if (__DEBUG__) {
    
  358.             console.log(
    
  359.               `parseSourceAST() Caching source metadata for "${originalSourceURL}"`,
    
  360.             );
    
  361.           }
    
  362. 
    
  363.           originalURLToMetadataCache.set(originalSourceURL, {
    
  364.             originalSourceAST,
    
  365.             originalSourceCode,
    
  366.           });
    
  367.         } catch (error) {
    
  368.           throw new Error(
    
  369.             `Failed to parse source file: ${originalSourceURL}\n\n` +
    
  370.               `Original error: ${error}`,
    
  371.           );
    
  372.         }
    
  373.       }
    
  374.     },
    
  375.   );
    
  376. }
    
  377. 
    
  378. function parseSourceMaps(
    
  379.   locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata,
    
  380.   locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata,
    
  381. ) {
    
  382.   locationKeyToHookSourceAndMetadata.forEach(
    
  383.     (hookSourceAndMetadata, locationKey) => {
    
  384.       const hookParsedMetadata =
    
  385.         locationKeyToHookParsedMetadata.get(locationKey);
    
  386.       if (hookParsedMetadata == null) {
    
  387.         throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`);
    
  388.       }
    
  389. 
    
  390.       const {runtimeSourceURL, sourceMapJSON} = hookSourceAndMetadata;
    
  391. 
    
  392.       // If we've already loaded the source map info for this file,
    
  393.       // we can skip reloading it (and more importantly, re-parsing it).
    
  394.       const runtimeMetadata = runtimeURLToMetadataCache.get(runtimeSourceURL);
    
  395.       if (runtimeMetadata != null) {
    
  396.         if (__DEBUG__) {
    
  397.           console.groupCollapsed(
    
  398.             `parseHookNames() Found cached runtime metadata for file "${runtimeSourceURL}"`,
    
  399.           );
    
  400.           console.log(runtimeMetadata);
    
  401.           console.groupEnd();
    
  402.         }
    
  403. 
    
  404.         hookParsedMetadata.metadataConsumer = runtimeMetadata.metadataConsumer;
    
  405.         hookParsedMetadata.sourceMapConsumer =
    
  406.           runtimeMetadata.sourceMapConsumer;
    
  407.       } else {
    
  408.         if (sourceMapJSON != null) {
    
  409.           const sourceMapConsumer = withSyncPerfMeasurements(
    
  410.             'new SourceMapConsumer(sourceMapJSON)',
    
  411.             () => SourceMapConsumer(sourceMapJSON),
    
  412.           );
    
  413. 
    
  414.           const metadataConsumer = withSyncPerfMeasurements(
    
  415.             'new SourceMapMetadataConsumer(sourceMapJSON)',
    
  416.             () => new SourceMapMetadataConsumer(sourceMapJSON),
    
  417.           );
    
  418. 
    
  419.           hookParsedMetadata.metadataConsumer = metadataConsumer;
    
  420.           hookParsedMetadata.sourceMapConsumer = sourceMapConsumer;
    
  421. 
    
  422.           // Only set once to avoid triggering eviction/cleanup code.
    
  423.           runtimeURLToMetadataCache.set(runtimeSourceURL, {
    
  424.             metadataConsumer: metadataConsumer,
    
  425.             sourceMapConsumer: sourceMapConsumer,
    
  426.           });
    
  427.         }
    
  428.       }
    
  429.     },
    
  430.   );
    
  431. }
    
  432. 
    
  433. export function purgeCachedMetadata(): void {
    
  434.   originalURLToMetadataCache.reset();
    
  435.   runtimeURLToMetadataCache.reset();
    
  436. }