| 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 | // Parsing source and source maps is done in a Web Worker |
| 11 | // because parsing is CPU intensive and should not block the UI thread. |
| 12 | // |
| 13 | // Fetching source and source map files is intentionally done on the UI thread |
| 14 | // so that loaded source files can reuse the browser's Network cache. |
| 15 | // Requests made from within an extension do not share the page's Network cache, |
| 16 | // but messages can be sent from the UI thread to the content script |
| 17 | // which can make a request from the page's context (with caching). |
| 18 | // |
| 19 | // Some overhead may be incurred sharing (serializing) the loaded data between contexts, |
| 20 | // but less than fetching the file to begin with, |
| 21 | // and in some cases we can avoid serializing the source code at all |
| 22 | // (e.g. when we are in an environment that supports our custom metadata format). |
| 23 | // |
| 24 | // The overall flow of this file is such: |
| 25 | // 1. Find the Set of source files defining the hooks and load them all. |
| 26 | // Then for each source file, do the following: |
| 27 | // |
| 28 | // a. Search loaded source file to see if a source map is available. |
| 29 | // If so, load that file and pass it to a Worker for parsing. |
| 30 | // The source map is used to retrieve the original source, |
| 31 | // which is then also parsed in the Worker to infer hook names. |
| 32 | // This is less ideal because parsing a full source map is slower, |
| 33 | // since we need to evaluate the mappings in order to map the runtime code to the original source, |
| 34 | // but at least the eventual source that we parse to an AST is small/fast. |
| 35 | // |
| 36 | // b. If no source map, pass the full source to a Worker for parsing. |
| 37 | // Use the source to infer hook names. |
| 38 | // This is the least optimal route as parsing the full source is very CPU intensive. |
| 39 | // |
| 40 | // In the future, we may add an additional optimization the above sequence. |
| 41 | // This check would come before the source map check: |
| 42 | // |
| 43 | // a. Search loaded source file to see if a custom React metadata file is available. |
| 44 | // If so, load that file and pass it to a Worker for parsing and extracting. |
| 45 | // This is the fastest option since our custom metadata file is much smaller than a full source map, |
| 46 | // and there is no need to convert runtime code to the original source. |
| 47 | |
| 48 | import {__DEBUG__} from 'react-devtools-shared/src/constants'; |
| 49 | import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookSourceLocation'; |
| 50 | import {sourceMapIncludesSource} from '../SourceMapUtils'; |
| 51 | import { |
| 52 | withAsyncPerfMeasurements, |
| 53 | withCallbackPerfMeasurements, |
| 54 | withSyncPerfMeasurements, |
| 55 | } from 'react-devtools-shared/src/PerformanceLoggingUtils'; |
| 56 | |
| 57 | import type { |
| 58 | HooksNode, |
| 59 | HookSource, |
| 60 | HooksTree, |
| 61 | } from 'react-debug-tools/src/ReactDebugHooks'; |
| 62 | import type {MixedSourceMap} from '../SourceMapTypes'; |
| 63 | import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; |
| 64 | |
| 65 | // Prefer a cached albeit stale response to reduce download time. |
| 66 | // We wouldn't want to load/parse a newer version of the source (even if one existed). |
| 67 | const FETCH_OPTIONS = {cache: 'force-cache' as CacheType}; |
| 68 | |
| 69 | const MAX_SOURCE_LENGTH = 100_000_000; |
| 70 | |
| 71 | export type HookSourceAndMetadata = { |
| 72 | // Generated by react-debug-tools. |
| 73 | hookSource: HookSource, |
| 74 | |
| 75 | // Compiled code (React components or custom hooks) containing primitive hook calls. |
| 76 | runtimeSourceCode: string | null, |
| 77 | |
| 78 | // Same as hookSource.fileName but guaranteed to be non-null. |
| 79 | runtimeSourceURL: string, |
| 80 | |
| 81 | // Raw source map JSON. |
| 82 | // Either decoded from an inline source map or loaded from an externa source map file. |
| 83 | // Sources without source maps won't have this. |
| 84 | sourceMapJSON: MixedSourceMap | null, |
| 85 | |
| 86 | // External URL of source map. |
| 87 | // Sources without source maps (or with inline source maps) won't have this. |
| 88 | sourceMapURL: string | null, |
| 89 | }; |
| 90 | |
| 91 | export type LocationKeyToHookSourceAndMetadata = Map< |
| 92 | string, |
| 93 | HookSourceAndMetadata, |
| 94 | >; |
| 95 | export type HooksList = Array<HooksNode>; |
| 96 | |
| 97 | export async function loadSourceAndMetadata( |
| 98 | hooksList: HooksList, |
| 99 | fetchFileWithCaching: FetchFileWithCaching | null, |
| 100 | ): Promise<LocationKeyToHookSourceAndMetadata> { |
| 101 | return withAsyncPerfMeasurements('loadSourceAndMetadata()', async () => { |
| 102 | const locationKeyToHookSourceAndMetadata = withSyncPerfMeasurements( |
| 103 | 'initializeHookSourceAndMetadata', |
| 104 | () => initializeHookSourceAndMetadata(hooksList), |
| 105 | ); |
| 106 | |
| 107 | await withAsyncPerfMeasurements('loadSourceFiles()', () => |
| 108 | loadSourceFiles(locationKeyToHookSourceAndMetadata, fetchFileWithCaching), |
| 109 | ); |
| 110 | |
| 111 | await withAsyncPerfMeasurements('extractAndLoadSourceMapJSON()', () => |
| 112 | extractAndLoadSourceMapJSON(locationKeyToHookSourceAndMetadata), |
| 113 | ); |
| 114 | |
| 115 | // At this point, we've loaded JS source (text) and source map (JSON). |
| 116 | // The remaining works (parsing these) is CPU intensive and should be done in a worker. |
| 117 | return locationKeyToHookSourceAndMetadata; |
| 118 | }); |
| 119 | } |
| 120 | |
| 121 | function decodeBase64String(encoded: string): Object { |
| 122 | return atob(encoded); |
| 123 | } |
| 124 | |
| 125 | function extractAndLoadSourceMapJSON( |
| 126 | locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, |
| 127 | ): Promise<mixed> { |
| 128 | // Deduplicate fetches, since there can be multiple location keys per source map. |
| 129 | const dedupedFetchPromises = new Map<string, Promise<$FlowFixMe>>(); |
| 130 | |
| 131 | // $FlowFixMe[constant-condition] |
| 132 | if (__DEBUG__) { |
| 133 | console.log( |
| 134 | 'extractAndLoadSourceMapJSON() load', |
| 135 | locationKeyToHookSourceAndMetadata.size, |
| 136 | 'source maps', |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | const setterPromises = []; |
| 141 | locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => { |
| 142 | const sourceMapRegex = / ?sourceMappingURL=([^\s'"]+)/gm; |
| 143 | const runtimeSourceCode = |
| 144 | hookSourceAndMetadata.runtimeSourceCode as any as string; |
| 145 | |
| 146 | // TODO (named hooks) Search for our custom metadata first. |
| 147 | // If it's found, we should use it rather than source maps. |
| 148 | |
| 149 | // TODO (named hooks) If this RegExp search is slow, we could try breaking it up |
| 150 | // first using an indexOf(' sourceMappingURL=') to find the start of the comment |
| 151 | // (probably at the end of the file) and then running the RegExp on the remaining substring. |
| 152 | let sourceMappingURLMatch = withSyncPerfMeasurements( |
| 153 | 'sourceMapRegex.exec(runtimeSourceCode)', |
| 154 | () => sourceMapRegex.exec(runtimeSourceCode), |
| 155 | ); |
| 156 | |
| 157 | if (sourceMappingURLMatch == null) { |
| 158 | // $FlowFixMe[constant-condition] |
| 159 | if (__DEBUG__) { |
| 160 | console.log('extractAndLoadSourceMapJSON() No source map found'); |
| 161 | } |
| 162 | |
| 163 | // Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST(). |
| 164 | } else { |
| 165 | const externalSourceMapURLs = []; |
| 166 | while (sourceMappingURLMatch != null) { |
| 167 | const {runtimeSourceURL} = hookSourceAndMetadata; |
| 168 | const sourceMappingURL = sourceMappingURLMatch[1]; |
| 169 | const hasInlineSourceMap = sourceMappingURL.indexOf('base64,') >= 0; |
| 170 | if (hasInlineSourceMap) { |
| 171 | try { |
| 172 | // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch) |
| 173 | // since there can be multiple location keys per source map. |
| 174 | |
| 175 | // Web apps like Code Sandbox embed multiple inline source maps. |
| 176 | // In this case, we need to loop through and find the right one. |
| 177 | // We may also need to trim any part of this string that isn't based64 encoded data. |
| 178 | const trimmed = ( |
| 179 | sourceMappingURL.match( |
| 180 | /base64,([a-zA-Z0-9+\/=]+)/, |
| 181 | ) as any as Array<string> |
| 182 | )[1]; |
| 183 | const decoded = withSyncPerfMeasurements( |
| 184 | 'decodeBase64String()', |
| 185 | () => decodeBase64String(trimmed), |
| 186 | ); |
| 187 | |
| 188 | const sourceMapJSON = withSyncPerfMeasurements( |
| 189 | 'JSON.parse(decoded)', |
| 190 | () => JSON.parse(decoded), |
| 191 | ); |
| 192 | |
| 193 | // $FlowFixMe[constant-condition] |
| 194 | if (__DEBUG__) { |
| 195 | console.groupCollapsed( |
| 196 | 'extractAndLoadSourceMapJSON() Inline source map', |
| 197 | ); |
| 198 | console.log(sourceMapJSON); |
| 199 | console.groupEnd(); |
| 200 | } |
| 201 | |
| 202 | // Hook source might be a URL like "https://4syus.csb.app/src/App.js" |
| 203 | // Parsed source map might be a partial path like "src/App.js" |
| 204 | if (sourceMapIncludesSource(sourceMapJSON, runtimeSourceURL)) { |
| 205 | hookSourceAndMetadata.sourceMapJSON = sourceMapJSON; |
| 206 | |
| 207 | // OPTIMIZATION If we've located a source map for this source, |
| 208 | // we'll use it to retrieve the original source (to extract hook names). |
| 209 | // We only fall back to parsing the full source code is when there's no source map. |
| 210 | // The source is (potentially) very large, |
| 211 | // So we can avoid the overhead of serializing it unnecessarily. |
| 212 | hookSourceAndMetadata.runtimeSourceCode = null; |
| 213 | |
| 214 | break; |
| 215 | } |
| 216 | } catch (error) { |
| 217 | // We've likely encountered a string in the source code that looks like a source map but isn't. |
| 218 | // Maybe the source code contains a "sourceMappingURL" comment or soething similar. |
| 219 | // In either case, let's skip this and keep looking. |
| 220 | } |
| 221 | } else { |
| 222 | externalSourceMapURLs.push(sourceMappingURL); |
| 223 | } |
| 224 | |
| 225 | // If the first source map we found wasn't a match, check for more. |
| 226 | sourceMappingURLMatch = withSyncPerfMeasurements( |
| 227 | 'sourceMapRegex.exec(runtimeSourceCode)', |
| 228 | () => sourceMapRegex.exec(runtimeSourceCode), |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | if (hookSourceAndMetadata.sourceMapJSON === null) { |
| 233 | externalSourceMapURLs.forEach((sourceMappingURL, index) => { |
| 234 | if (index !== externalSourceMapURLs.length - 1) { |
| 235 | // Files with external source maps should only have a single source map. |
| 236 | // More than one result might indicate an edge case, |
| 237 | // like a string in the source code that matched our "sourceMappingURL" regex. |
| 238 | // We should just skip over cases like this. |
| 239 | console.warn( |
| 240 | `More than one external source map detected in the source file; skipping "${sourceMappingURL}"`, |
| 241 | ); |
| 242 | return; |
| 243 | } |
| 244 | |
| 245 | const {runtimeSourceURL} = hookSourceAndMetadata; |
| 246 | let url = sourceMappingURL; |
| 247 | if (!url.startsWith('http') && !url.startsWith('/')) { |
| 248 | // Resolve paths relative to the location of the file name |
| 249 | const lastSlashIdx = runtimeSourceURL.lastIndexOf('/'); |
| 250 | if (lastSlashIdx !== -1) { |
| 251 | const baseURL = runtimeSourceURL.slice( |
| 252 | 0, |
| 253 | runtimeSourceURL.lastIndexOf('/'), |
| 254 | ); |
| 255 | url = `${baseURL}/${url}`; |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | hookSourceAndMetadata.sourceMapURL = url; |
| 260 | |
| 261 | const fetchPromise = |
| 262 | dedupedFetchPromises.get(url) || |
| 263 | fetchFile(url).then( |
| 264 | sourceMapContents => { |
| 265 | const sourceMapJSON = withSyncPerfMeasurements( |
| 266 | 'JSON.parse(sourceMapContents)', |
| 267 | () => JSON.parse(sourceMapContents), |
| 268 | ); |
| 269 | |
| 270 | return sourceMapJSON; |
| 271 | }, |
| 272 | |
| 273 | // In this case, we fall back to the assumption that the source has no source map. |
| 274 | // This might indicate an (unlikely) edge case that had no source map, |
| 275 | // but contained the string "sourceMappingURL". |
| 276 | error => null, |
| 277 | ); |
| 278 | |
| 279 | // $FlowFixMe[constant-condition] |
| 280 | if (__DEBUG__) { |
| 281 | if (!dedupedFetchPromises.has(url)) { |
| 282 | console.log( |
| 283 | `extractAndLoadSourceMapJSON() External source map "${url}"`, |
| 284 | ); |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | dedupedFetchPromises.set(url, fetchPromise); |
| 289 | |
| 290 | setterPromises.push( |
| 291 | fetchPromise.then(sourceMapJSON => { |
| 292 | if (sourceMapJSON !== null) { |
| 293 | hookSourceAndMetadata.sourceMapJSON = sourceMapJSON; |
| 294 | |
| 295 | // OPTIMIZATION If we've located a source map for this source, |
| 296 | // we'll use it to retrieve the original source (to extract hook names). |
| 297 | // We only fall back to parsing the full source code is when there's no source map. |
| 298 | // The source is (potentially) very large, |
| 299 | // So we can avoid the overhead of serializing it unnecessarily. |
| 300 | hookSourceAndMetadata.runtimeSourceCode = null; |
| 301 | } |
| 302 | }), |
| 303 | ); |
| 304 | }); |
| 305 | } |
| 306 | } |
| 307 | }); |
| 308 | |
| 309 | return Promise.all(setterPromises); |
| 310 | } |
| 311 | |
| 312 | function fetchFile( |
| 313 | url: string, |
| 314 | markName: string = 'fetchFile', |
| 315 | ): Promise<string> { |
| 316 | return withCallbackPerfMeasurements(`${markName}("${url}")`, done => { |
| 317 | return new Promise((resolve, reject) => { |
| 318 | // $FlowFixMe[incompatible-type] |
| 319 | fetch(url, FETCH_OPTIONS).then( |
| 320 | response => { |
| 321 | if (response.ok) { |
| 322 | response |
| 323 | .text() |
| 324 | .then(text => { |
| 325 | done(); |
| 326 | resolve(text); |
| 327 | }) |
| 328 | .catch(error => { |
| 329 | // $FlowFixMe[constant-condition] |
| 330 | if (__DEBUG__) { |
| 331 | console.log( |
| 332 | `${markName}() Could not read text for url "${url}"`, |
| 333 | ); |
| 334 | } |
| 335 | done(); |
| 336 | reject(null); |
| 337 | }); |
| 338 | } else { |
| 339 | // $FlowFixMe[constant-condition] |
| 340 | if (__DEBUG__) { |
| 341 | console.log(`${markName}() Got bad response for url "${url}"`); |
| 342 | } |
| 343 | done(); |
| 344 | reject(null); |
| 345 | } |
| 346 | }, |
| 347 | error => { |
| 348 | // $FlowFixMe[constant-condition] |
| 349 | if (__DEBUG__) { |
| 350 | console.log(`${markName}() Could not fetch file: ${error.message}`); |
| 351 | } |
| 352 | done(); |
| 353 | reject(null); |
| 354 | }, |
| 355 | ); |
| 356 | }); |
| 357 | }); |
| 358 | } |
| 359 | |
| 360 | export function hasNamedHooks(hooksTree: HooksTree): boolean { |
| 361 | for (let i = 0; i < hooksTree.length; i++) { |
| 362 | const hook = hooksTree[i]; |
| 363 | |
| 364 | if (!isUnnamedBuiltInHook(hook)) { |
| 365 | return true; |
| 366 | } |
| 367 | |
| 368 | if (hook.subHooks.length > 0) { |
| 369 | if (hasNamedHooks(hook.subHooks)) { |
| 370 | return true; |
| 371 | } |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | return false; |
| 376 | } |
| 377 | |
| 378 | export function flattenHooksList(hooksTree: HooksTree): HooksList { |
| 379 | const hooksList: HooksList = []; |
| 380 | withSyncPerfMeasurements('flattenHooksList()', () => { |
| 381 | flattenHooksListImpl(hooksTree, hooksList); |
| 382 | }); |
| 383 | |
| 384 | // $FlowFixMe[constant-condition] |
| 385 | if (__DEBUG__) { |
| 386 | console.log('flattenHooksList() hooksList:', hooksList); |
| 387 | } |
| 388 | |
| 389 | return hooksList; |
| 390 | } |
| 391 | |
| 392 | function flattenHooksListImpl( |
| 393 | hooksTree: HooksTree, |
| 394 | hooksList: Array<HooksNode>, |
| 395 | ): void { |
| 396 | for (let i = 0; i < hooksTree.length; i++) { |
| 397 | const hook = hooksTree[i]; |
| 398 | |
| 399 | if (isUnnamedBuiltInHook(hook)) { |
| 400 | // No need to load source code or do any parsing for unnamed hooks. |
| 401 | // $FlowFixMe[constant-condition] |
| 402 | if (__DEBUG__) { |
| 403 | console.log('flattenHooksListImpl() Skipping unnamed hook', hook); |
| 404 | } |
| 405 | |
| 406 | continue; |
| 407 | } |
| 408 | |
| 409 | hooksList.push(hook); |
| 410 | |
| 411 | if (hook.subHooks.length > 0) { |
| 412 | flattenHooksListImpl(hook.subHooks, hooksList); |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | function initializeHookSourceAndMetadata( |
| 418 | hooksList: Array<HooksNode>, |
| 419 | ): LocationKeyToHookSourceAndMetadata { |
| 420 | // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks. |
| 421 | const locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata = |
| 422 | new Map(); |
| 423 | for (let i = 0; i < hooksList.length; i++) { |
| 424 | const hook = hooksList[i]; |
| 425 | |
| 426 | const hookSource = hook.hookSource; |
| 427 | if (hookSource == null) { |
| 428 | // Older versions of react-debug-tools don't include this information. |
| 429 | // In this case, we can't continue. |
| 430 | throw Error('Hook source code location not found.'); |
| 431 | } |
| 432 | |
| 433 | const locationKey = getHookSourceLocationKey(hookSource); |
| 434 | if (!locationKeyToHookSourceAndMetadata.has(locationKey)) { |
| 435 | // Can't be null because getHookSourceLocationKey() would have thrown |
| 436 | const runtimeSourceURL = hookSource.fileName as any as string; |
| 437 | |
| 438 | const hookSourceAndMetadata: HookSourceAndMetadata = { |
| 439 | hookSource, |
| 440 | runtimeSourceCode: null, |
| 441 | runtimeSourceURL, |
| 442 | sourceMapJSON: null, |
| 443 | sourceMapURL: null, |
| 444 | }; |
| 445 | |
| 446 | locationKeyToHookSourceAndMetadata.set( |
| 447 | locationKey, |
| 448 | hookSourceAndMetadata, |
| 449 | ); |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | return locationKeyToHookSourceAndMetadata; |
| 454 | } |
| 455 | |
| 456 | // Determines whether incoming hook is a primitive hook that gets assigned to variables. |
| 457 | function isUnnamedBuiltInHook(hook: HooksNode) { |
| 458 | return ['Effect', 'ImperativeHandle', 'LayoutEffect', 'DebugValue'].includes( |
| 459 | hook.name, |
| 460 | ); |
| 461 | } |
| 462 | |
| 463 | function loadSourceFiles( |
| 464 | locationKeyToHookSourceAndMetadata: LocationKeyToHookSourceAndMetadata, |
| 465 | fetchFileWithCaching: FetchFileWithCaching | null, |
| 466 | ): Promise<mixed> { |
| 467 | // Deduplicate fetches, since there can be multiple location keys per file. |
| 468 | const dedupedFetchPromises = new Map<string, Promise<$FlowFixMe>>(); |
| 469 | |
| 470 | const setterPromises = []; |
| 471 | locationKeyToHookSourceAndMetadata.forEach(hookSourceAndMetadata => { |
| 472 | const {runtimeSourceURL} = hookSourceAndMetadata; |
| 473 | |
| 474 | let fetchFileFunction = fetchFile; |
| 475 | if (fetchFileWithCaching != null) { |
| 476 | // If a helper function has been injected to fetch with caching, |
| 477 | // use it to fetch the (already loaded) source file. |
| 478 | fetchFileFunction = url => { |
| 479 | return withAsyncPerfMeasurements( |
| 480 | `fetchFileWithCaching("${url}")`, |
| 481 | () => { |
| 482 | return (fetchFileWithCaching as any as FetchFileWithCaching)(url); |
| 483 | }, |
| 484 | ); |
| 485 | }; |
| 486 | } |
| 487 | |
| 488 | const fetchPromise = |
| 489 | dedupedFetchPromises.get(runtimeSourceURL) || |
| 490 | (runtimeSourceURL && !runtimeSourceURL.startsWith('<anonymous') |
| 491 | ? fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => { |
| 492 | // TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps, |
| 493 | // because then we need to parse the full source file as an AST. |
| 494 | if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) { |
| 495 | throw Error('Source code too large to parse'); |
| 496 | } |
| 497 | |
| 498 | // $FlowFixMe[constant-condition] |
| 499 | if (__DEBUG__) { |
| 500 | console.groupCollapsed( |
| 501 | `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`, |
| 502 | ); |
| 503 | console.log(runtimeSourceCode); |
| 504 | console.groupEnd(); |
| 505 | } |
| 506 | |
| 507 | return runtimeSourceCode; |
| 508 | }) |
| 509 | : Promise.reject(new Error('Empty url'))); |
| 510 | dedupedFetchPromises.set(runtimeSourceURL, fetchPromise); |
| 511 | |
| 512 | setterPromises.push( |
| 513 | fetchPromise.then(runtimeSourceCode => { |
| 514 | hookSourceAndMetadata.runtimeSourceCode = runtimeSourceCode; |
| 515 | }), |
| 516 | ); |
| 517 | }); |
| 518 | |
| 519 | return Promise.all(setterPromises); |
| 520 | } |