| 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/hookSourceLocation'; |
| 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 | // $FlowFixMe[constant-condition] |
| 86 | if (__DEBUG__) { |
| 87 | console.log( |
| 88 | `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`, |
| 89 | ); |
| 90 | } |
| 91 | }, |
| 92 | }); |
| 93 | |
| 94 | export async function parseSourceAndMetadata( |
| 95 | hooksList: HooksList, |
| 96 | locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, |
| 97 | ): Promise<HookNames | null> { |
| 98 | return withAsyncPerfMeasurements('parseSourceAndMetadata()', async () => { |
| 99 | const locationKeyToHookParsedMetadata = withSyncPerfMeasurements( |
| 100 | 'initializeHookParsedMetadata', |
| 101 | () => initializeHookParsedMetadata(locationKeyToHookSourceAndMetadata), |
| 102 | ); |
| 103 | |
| 104 | withSyncPerfMeasurements('parseSourceMaps', () => |
| 105 | parseSourceMaps( |
| 106 | locationKeyToHookSourceAndMetadata, |
| 107 | locationKeyToHookParsedMetadata, |
| 108 | ), |
| 109 | ); |
| 110 | |
| 111 | withSyncPerfMeasurements('parseSourceAST()', () => |
| 112 | parseSourceAST( |
| 113 | locationKeyToHookSourceAndMetadata, |
| 114 | locationKeyToHookParsedMetadata, |
| 115 | ), |
| 116 | ); |
| 117 | |
| 118 | return withSyncPerfMeasurements('findHookNames()', () => |
| 119 | findHookNames(hooksList, locationKeyToHookParsedMetadata), |
| 120 | ); |
| 121 | }); |
| 122 | } |
| 123 | |
| 124 | function findHookNames( |
| 125 | hooksList: HooksList, |
| 126 | locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata, |
| 127 | ): HookNames { |
| 128 | const map: HookNames = new Map(); |
| 129 | |
| 130 | hooksList.map(hook => { |
| 131 | // We already guard against a null HookSource in parseHookNames() |
| 132 | const hookSource = hook.hookSource as any as HookSource; |
| 133 | const fileName = hookSource.fileName; |
| 134 | if (!fileName) { |
| 135 | return null; // Should not be reachable. |
| 136 | } |
| 137 | |
| 138 | const locationKey = getHookSourceLocationKey(hookSource); |
| 139 | const hookParsedMetadata = locationKeyToHookParsedMetadata.get(locationKey); |
| 140 | if (!hookParsedMetadata) { |
| 141 | return null; // Should not be reachable. |
| 142 | } |
| 143 | |
| 144 | const {lineNumber, columnNumber} = hookSource; |
| 145 | if (!lineNumber || !columnNumber) { |
| 146 | return null; // Should not be reachable. |
| 147 | } |
| 148 | |
| 149 | const { |
| 150 | originalSourceURL, |
| 151 | originalSourceColumnNumber, |
| 152 | originalSourceLineNumber, |
| 153 | } = hookParsedMetadata; |
| 154 | |
| 155 | if ( |
| 156 | originalSourceLineNumber == null || |
| 157 | originalSourceColumnNumber == null || |
| 158 | originalSourceURL == null |
| 159 | ) { |
| 160 | return null; // Should not be reachable. |
| 161 | } |
| 162 | |
| 163 | let name; |
| 164 | const {metadataConsumer} = hookParsedMetadata; |
| 165 | if (metadataConsumer != null) { |
| 166 | name = withSyncPerfMeasurements('metadataConsumer.hookNameFor()', () => |
| 167 | metadataConsumer.hookNameFor({ |
| 168 | line: originalSourceLineNumber, |
| 169 | column: originalSourceColumnNumber, |
| 170 | source: originalSourceURL, |
| 171 | }), |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | if (name == null) { |
| 176 | name = withSyncPerfMeasurements('getHookName()', () => |
| 177 | getHookName( |
| 178 | hook, |
| 179 | hookParsedMetadata.originalSourceAST, |
| 180 | hookParsedMetadata.originalSourceCode as any as string, |
| 181 | originalSourceLineNumber as any as number, |
| 182 | originalSourceColumnNumber, |
| 183 | ), |
| 184 | ); |
| 185 | } |
| 186 | |
| 187 | // $FlowFixMe[constant-condition] |
| 188 | if (__DEBUG__) { |
| 189 | console.log(`findHookNames() Found name "${name || '-'}"`); |
| 190 | } |
| 191 | |
| 192 | const key = getHookSourceLocationKey(hookSource); |
| 193 | map.set(key, name); |
| 194 | }); |
| 195 | |
| 196 | return map; |
| 197 | } |
| 198 | |
| 199 | function initializeHookParsedMetadata( |
| 200 | locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, |
| 201 | ) { |
| 202 | // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks. |
| 203 | const locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata = |
| 204 | new Map(); |
| 205 | locationKeyToHookSourceAndMetadata.forEach( |
| 206 | (hookSourceAndMetadata, locationKey) => { |
| 207 | const hookParsedMetadata: HookParsedMetadata = { |
| 208 | metadataConsumer: null, |
| 209 | originalSourceAST: null, |
| 210 | originalSourceCode: null, |
| 211 | originalSourceURL: null, |
| 212 | originalSourceLineNumber: null, |
| 213 | originalSourceColumnNumber: null, |
| 214 | sourceMapConsumer: null, |
| 215 | }; |
| 216 | |
| 217 | locationKeyToHookParsedMetadata.set(locationKey, hookParsedMetadata); |
| 218 | }, |
| 219 | ); |
| 220 | |
| 221 | return locationKeyToHookParsedMetadata; |
| 222 | } |
| 223 | |
| 224 | function parseSourceAST( |
| 225 | locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, |
| 226 | locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata, |
| 227 | ): void { |
| 228 | locationKeyToHookSourceAndMetadata.forEach( |
| 229 | (hookSourceAndMetadata, locationKey) => { |
| 230 | const hookParsedMetadata = |
| 231 | locationKeyToHookParsedMetadata.get(locationKey); |
| 232 | if (hookParsedMetadata == null) { |
| 233 | throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`); |
| 234 | } |
| 235 | |
| 236 | if (hookParsedMetadata.originalSourceAST !== null) { |
| 237 | // Use cached metadata. |
| 238 | return; |
| 239 | } |
| 240 | |
| 241 | if ( |
| 242 | hookParsedMetadata.originalSourceURL != null && |
| 243 | hookParsedMetadata.originalSourceCode != null && |
| 244 | hookParsedMetadata.originalSourceColumnNumber != null && |
| 245 | hookParsedMetadata.originalSourceLineNumber != null |
| 246 | ) { |
| 247 | // Use cached metadata. |
| 248 | return; |
| 249 | } |
| 250 | |
| 251 | const {lineNumber, columnNumber} = hookSourceAndMetadata.hookSource; |
| 252 | if (lineNumber == null || columnNumber == null) { |
| 253 | throw Error('Hook source code location not found.'); |
| 254 | } |
| 255 | |
| 256 | const {metadataConsumer, sourceMapConsumer} = hookParsedMetadata; |
| 257 | const runtimeSourceCode = |
| 258 | hookSourceAndMetadata.runtimeSourceCode as any as string; |
| 259 | let hasHookMap = false; |
| 260 | let originalSourceURL; |
| 261 | let originalSourceCode; |
| 262 | let originalSourceColumnNumber; |
| 263 | let originalSourceLineNumber; |
| 264 | if (areSourceMapsAppliedToErrors() || sourceMapConsumer === null) { |
| 265 | // Either the current environment automatically applies source maps to errors, |
| 266 | // or the current code had no source map to begin with. |
| 267 | // Either way, we don't need to convert the Error stack frame locations. |
| 268 | originalSourceColumnNumber = columnNumber; |
| 269 | originalSourceLineNumber = lineNumber; |
| 270 | // There's no source map to parse here so we can just parse the original source itself. |
| 271 | originalSourceCode = runtimeSourceCode; |
| 272 | // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space. |
| 273 | // Namespace them? |
| 274 | originalSourceURL = hookSourceAndMetadata.runtimeSourceURL; |
| 275 | } else { |
| 276 | const {column, line, sourceContent, sourceURL} = |
| 277 | sourceMapConsumer.originalPositionFor({ |
| 278 | columnNumber, |
| 279 | lineNumber, |
| 280 | }); |
| 281 | if (sourceContent === null || sourceURL === null) { |
| 282 | throw Error( |
| 283 | `Could not find original source for line:${lineNumber} and column:${columnNumber}`, |
| 284 | ); |
| 285 | } |
| 286 | |
| 287 | originalSourceColumnNumber = column; |
| 288 | originalSourceLineNumber = line; |
| 289 | originalSourceCode = sourceContent; |
| 290 | originalSourceURL = sourceURL; |
| 291 | } |
| 292 | |
| 293 | hookParsedMetadata.originalSourceCode = originalSourceCode; |
| 294 | hookParsedMetadata.originalSourceURL = originalSourceURL; |
| 295 | hookParsedMetadata.originalSourceLineNumber = originalSourceLineNumber; |
| 296 | hookParsedMetadata.originalSourceColumnNumber = |
| 297 | originalSourceColumnNumber; |
| 298 | |
| 299 | if ( |
| 300 | metadataConsumer != null && |
| 301 | metadataConsumer.hasHookMap(originalSourceURL) |
| 302 | ) { |
| 303 | hasHookMap = true; |
| 304 | } |
| 305 | |
| 306 | // $FlowFixMe[constant-condition] |
| 307 | if (__DEBUG__) { |
| 308 | console.log( |
| 309 | `parseSourceAST() mapped line ${lineNumber}->${originalSourceLineNumber} and column ${columnNumber}->${originalSourceColumnNumber}`, |
| 310 | ); |
| 311 | } |
| 312 | |
| 313 | if (hasHookMap) { |
| 314 | // $FlowFixMe[constant-condition] |
| 315 | if (__DEBUG__) { |
| 316 | console.log( |
| 317 | `parseSourceAST() Found hookMap and skipping parsing for "${originalSourceURL}"`, |
| 318 | ); |
| 319 | } |
| 320 | // If there's a hook map present from an extended sourcemap then |
| 321 | // we don't need to parse the source files and instead can use the |
| 322 | // hook map to extract hook names. |
| 323 | return; |
| 324 | } |
| 325 | |
| 326 | // $FlowFixMe[constant-condition] |
| 327 | if (__DEBUG__) { |
| 328 | console.log( |
| 329 | `parseSourceAST() Did not find hook map for "${originalSourceURL}"`, |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | // The cache also serves to deduplicate parsing by URL in our loop over location keys. |
| 334 | // This may need to change if we switch to async parsing. |
| 335 | const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL); |
| 336 | if (sourceMetadata != null) { |
| 337 | // $FlowFixMe[constant-condition] |
| 338 | if (__DEBUG__) { |
| 339 | console.groupCollapsed( |
| 340 | `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`, |
| 341 | ); |
| 342 | console.log(sourceMetadata); |
| 343 | console.groupEnd(); |
| 344 | } |
| 345 | hookParsedMetadata.originalSourceAST = sourceMetadata.originalSourceAST; |
| 346 | hookParsedMetadata.originalSourceCode = |
| 347 | sourceMetadata.originalSourceCode; |
| 348 | } else { |
| 349 | try { |
| 350 | // TypeScript is the most commonly used typed JS variant so let's default to it |
| 351 | // unless we detect explicit Flow usage via the "@flow" pragma. |
| 352 | const plugin = |
| 353 | originalSourceCode.indexOf('@flow') > 0 ? 'flow' : 'typescript'; |
| 354 | |
| 355 | // TODO (named hooks) This is probably where we should check max source length, |
| 356 | // rather than in loadSourceAndMetatada -> loadSourceFiles(). |
| 357 | // TODO(#22319): Support source files that are html files with inline script tags. |
| 358 | const originalSourceAST = withSyncPerfMeasurements( |
| 359 | '[@babel/parser] parse(originalSourceCode)', |
| 360 | () => |
| 361 | parse(originalSourceCode, { |
| 362 | sourceType: 'unambiguous', |
| 363 | plugins: ['jsx', plugin], |
| 364 | }), |
| 365 | ); |
| 366 | hookParsedMetadata.originalSourceAST = originalSourceAST; |
| 367 | |
| 368 | // $FlowFixMe[constant-condition] |
| 369 | if (__DEBUG__) { |
| 370 | console.log( |
| 371 | `parseSourceAST() Caching source metadata for "${originalSourceURL}"`, |
| 372 | ); |
| 373 | } |
| 374 | |
| 375 | originalURLToMetadataCache.set(originalSourceURL, { |
| 376 | originalSourceAST, |
| 377 | originalSourceCode, |
| 378 | }); |
| 379 | } catch (error) { |
| 380 | throw new Error( |
| 381 | `Failed to parse source file: ${originalSourceURL}\n\n` + |
| 382 | `Original error: ${error}`, |
| 383 | ); |
| 384 | } |
| 385 | } |
| 386 | }, |
| 387 | ); |
| 388 | } |
| 389 | |
| 390 | function parseSourceMaps( |
| 391 | locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, |
| 392 | locationKeyToHookParsedMetadata: LocationKeyToHookParsedMetadata, |
| 393 | ) { |
| 394 | locationKeyToHookSourceAndMetadata.forEach( |
| 395 | (hookSourceAndMetadata, locationKey) => { |
| 396 | const hookParsedMetadata = |
| 397 | locationKeyToHookParsedMetadata.get(locationKey); |
| 398 | if (hookParsedMetadata == null) { |
| 399 | throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`); |
| 400 | } |
| 401 | |
| 402 | const {runtimeSourceURL, sourceMapJSON} = hookSourceAndMetadata; |
| 403 | |
| 404 | // If we've already loaded the source map info for this file, |
| 405 | // we can skip reloading it (and more importantly, re-parsing it). |
| 406 | const runtimeMetadata = runtimeURLToMetadataCache.get(runtimeSourceURL); |
| 407 | if (runtimeMetadata != null) { |
| 408 | // $FlowFixMe[constant-condition] |
| 409 | if (__DEBUG__) { |
| 410 | console.groupCollapsed( |
| 411 | `parseHookNames() Found cached runtime metadata for file "${runtimeSourceURL}"`, |
| 412 | ); |
| 413 | console.log(runtimeMetadata); |
| 414 | console.groupEnd(); |
| 415 | } |
| 416 | |
| 417 | hookParsedMetadata.metadataConsumer = runtimeMetadata.metadataConsumer; |
| 418 | hookParsedMetadata.sourceMapConsumer = |
| 419 | runtimeMetadata.sourceMapConsumer; |
| 420 | } else { |
| 421 | if (sourceMapJSON != null) { |
| 422 | const sourceMapConsumer = withSyncPerfMeasurements( |
| 423 | 'new SourceMapConsumer(sourceMapJSON)', |
| 424 | () => SourceMapConsumer(sourceMapJSON), |
| 425 | ); |
| 426 | |
| 427 | const metadataConsumer = withSyncPerfMeasurements( |
| 428 | 'new SourceMapMetadataConsumer(sourceMapJSON)', |
| 429 | () => new SourceMapMetadataConsumer(sourceMapJSON), |
| 430 | ); |
| 431 | |
| 432 | hookParsedMetadata.metadataConsumer = metadataConsumer; |
| 433 | hookParsedMetadata.sourceMapConsumer = sourceMapConsumer; |
| 434 | |
| 435 | // Only set once to avoid triggering eviction/cleanup code. |
| 436 | runtimeURLToMetadataCache.set(runtimeSourceURL, { |
| 437 | metadataConsumer: metadataConsumer, |
| 438 | sourceMapConsumer: sourceMapConsumer, |
| 439 | }); |
| 440 | } |
| 441 | } |
| 442 | }, |
| 443 | ); |
| 444 | } |
| 445 | |
| 446 | export function purgeCachedMetadata(): void { |
| 447 | originalURLToMetadataCache.reset(); |
| 448 | runtimeURLToMetadataCache.reset(); |
| 449 | } |