24
} from 'react-debug-tools/src/ReactDebugHooks';
25
import type {HookNames, LRUCache} from 'react-devtools-shared/src/types';
26
import type {Thenable} from 'shared/ReactTypes';
27
-import type {SourceConsumer, SourceMap} from './astUtils';
27
+import type {SourceConsumer} from './astUtils';
28
29
const SOURCE_MAP_REGEX = / ?sourceMappingURL=([^\s'"]+)/gm;
30
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
43
// If no source map has been provided, this code will be the same as runtimeSourceCode.
44
originalSourceCode: string | null,
45
46
+ // Original source URL if there is a source map, or the same as runtimeSourceURL.
47
+ originalSourceURL: string | null,
48
+
49
// Compiled code (React components or custom hooks) containing primitive hook calls.
50
runtimeSourceCode: string | null,
51
52
+ // Same as hookSource.fileName but guaranteed to be non-null.
53
+ runtimeSourceURL: string,
54
+
55
// APIs from source-map for parsing source maps (if detected).
56
sourceConsumer: SourceConsumer | null,
57
58
// External URL of source map.
59
// Sources without source maps (or with inline source maps) won't have this.
60
sourceMapURL: string | null,
55
-
56
- // Parsed source map object.
57
- sourceMapContents: SourceMap | null,
61
|};
62
60
-type CachedMetadata = {|
61
- originalSourceAST: AST,
62
- originalSourceCode: string,
63
+type CachedRuntimeCodeMetadata = {|
64
sourceConsumer: SourceConsumer | null,
65
|};
66
66
-// On large trees, encoding takes significant time.
67
-// Try to reuse the already encoded strings.
68
-const fileNameToMetadataCache: LRUCache<string, CachedMetadata> = new LRU({
67
+const runtimeURLToMetadataCache: LRUCache<
68
+ string,
69
+ CachedRuntimeCodeMetadata,
70
+> = new LRU({
71
max: 50,
70
- dispose: (fileName: string, metadata: CachedMetadata) => {
72
+ dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => {
73
if (__DEBUG__) {
74
console.log(
73
- 'fileNameToHookSourceData.dispose() Evicting cached metadata for "' +
74
- fileName +
75
- '"',
75
+ `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`,
76
);
77
}
78
83
},
84
});
85
86
+type CachedSourceCodeMetadata = {|
87
+ originalSourceAST: AST,
88
+ originalSourceCode: string,
89
+|};
90
+
91
+const originalURLToMetadataCache: LRUCache<
92
+ string,
93
+ CachedSourceCodeMetadata,
94
+> = new LRU({
95
+ max: 50,
96
+ dispose: (originalSourceURL: string, metadata: CachedSourceCodeMetadata) => {
97
+ if (__DEBUG__) {
98
+ console.log(
99
+ `originalURLToMetadataCache.dispose() Evicting cached metadata for "${originalSourceURL}"`,
100
+ );
101
+ }
102
+ },
103
+});
104
+
105
+function getLocationKey({
106
+ fileName,
107
+ lineNumber,
108
+ columnNumber,
109
+}: HookSource): string {
110
+ if (fileName == null || lineNumber == null || columnNumber == null) {
111
+ throw Error('Hook source code location not found.');
112
+ }
113
+ return `${fileName}:${lineNumber}:${columnNumber}`;
114
+}
115
+
116
export default async function parseHookNames(
117
hooksTree: HooksTree,
118
): Thenable<HookNames | null> {
127
console.log('parseHookNames() hooksList:', hooksList);
128
}
129
100
- // Gather the unique set of source files to load for the built-in hooks.
101
- const fileNameToHookSourceData: Map<string, HookSourceData> = new Map();
130
+ // Create map of unique source locations (file names plus line and column numbers) to metadata about hooks.
131
+ const locationKeyToHookSourceData: Map<string, HookSourceData> = new Map();
132
for (let i = 0; i < hooksList.length; i++) {
133
const hook = hooksList[i];
134
139
throw Error('Hook source code location not found.');
140
}
141
112
- const fileName = hookSource.fileName;
113
- if (fileName == null) {
114
- throw Error('Hook source code location not found.');
115
- } else {
116
- if (!fileNameToHookSourceData.has(fileName)) {
117
- const hookSourceData: HookSourceData = {
118
- hookSource,
119
- originalSourceAST: null,
120
- originalSourceCode: null,
121
- runtimeSourceCode: null,
122
- sourceConsumer: null,
123
- sourceMapURL: null,
124
- sourceMapContents: null,
125
- };
126
-
127
- // If we've already loaded source/source map info for this file,
128
- // we can skip reloading it (and more importantly, re-parsing it).
129
- const metadata = fileNameToMetadataCache.get(fileName);
130
- if (metadata != null) {
131
- if (__DEBUG__) {
132
- console.groupCollapsed(
133
- 'parseHookNames() Found cached metadata for file "' +
134
- fileName +
135
- '"',
136
- );
137
- console.log(metadata);
138
- console.groupEnd();
139
- }
140
-
141
- hookSourceData.originalSourceAST = metadata.originalSourceAST;
142
- hookSourceData.originalSourceCode = metadata.originalSourceCode;
143
- hookSourceData.sourceConsumer = metadata.sourceConsumer;
142
+ const locationKey = getLocationKey(hookSource);
143
+ if (!locationKeyToHookSourceData.has(locationKey)) {
144
+ // Can't be null because getLocationKey() would have thrown
145
+ const runtimeSourceURL = ((hookSource.fileName: any): string);
146
+
147
+ const hookSourceData: HookSourceData = {
148
+ hookSource,
149
+ originalSourceAST: null,
150
+ originalSourceCode: null,
151
+ originalSourceURL: null,
152
+ runtimeSourceCode: null,
153
+ runtimeSourceURL,
154
+ sourceConsumer: null,
155
+ sourceMapURL: null,
156
+ };
157
+
158
+ // If we've already loaded the source map info for this file,
159
+ // we can skip reloading it (and more importantly, re-parsing it).
160
+ const runtimeMetadata = runtimeURLToMetadataCache.get(
161
+ hookSourceData.runtimeSourceURL,
162
+ );
163
+ if (runtimeMetadata != null) {
164
+ if (__DEBUG__) {
165
+ console.groupCollapsed(
166
+ `parseHookNames() Found cached runtime metadata for file "${hookSourceData.runtimeSourceURL}"`,
167
+ );
168
+ console.log(runtimeMetadata);
169
+ console.groupEnd();
170
}
145
-
146
- fileNameToHookSourceData.set(fileName, hookSourceData);
171
+ hookSourceData.sourceConsumer = runtimeMetadata.sourceConsumer;
172
}
173
+
174
+ locationKeyToHookSourceData.set(locationKey, hookSourceData);
175
}
176
}
177
151
- return loadSourceFiles(fileNameToHookSourceData)
152
- .then(() => extractAndLoadSourceMaps(fileNameToHookSourceData))
153
- .then(() => parseSourceAST(fileNameToHookSourceData))
154
- .then(() => updateLruCache(fileNameToHookSourceData))
155
- .then(() => findHookNames(hooksList, fileNameToHookSourceData));
178
+ return loadSourceFiles(locationKeyToHookSourceData)
179
+ .then(() => extractAndLoadSourceMaps(locationKeyToHookSourceData))
180
+ .then(() => parseSourceAST(locationKeyToHookSourceData))
181
+ .then(() => updateLruCache(locationKeyToHookSourceData))
182
+ .then(() => findHookNames(hooksList, locationKeyToHookSourceData));
183
}
184
185
function decodeBase64String(encoded: string): Object {
197
}
198
199
function extractAndLoadSourceMaps(
173
- fileNameToHookSourceData: Map<string, HookSourceData>,
200
+ locationKeyToHookSourceData: Map<string, HookSourceData>,
201
): Promise<*> {
175
- const promises = [];
176
- fileNameToHookSourceData.forEach(hookSourceData => {
177
- if (hookSourceData.originalSourceAST !== null) {
178
- // Use cached metadata.
202
+ // SourceMapConsumer.initialize() does nothing when running in Node (aka Jest)
203
+ // because the wasm file is automatically read from the file system
204
+ // so we can avoid triggering a warning message about this.
205
+ if (!__TEST__) {
206
+ if (__DEBUG__) {
207
+ console.log(
208
+ 'extractAndLoadSourceMaps() Initializing source-map library ...',
209
+ );
210
+ }
211
+
212
+ // $FlowFixMe
213
+ const wasmMappingsURL = chrome.extension.getURL('mappings.wasm');
214
+
215
+ SourceMapConsumer.initialize({'lib/mappings.wasm': wasmMappingsURL});
216
+ }
217
+
218
+ // Deduplicate fetches, since there can be multiple location keys per source map.
219
+ const fetchPromises = new Map();
220
+
221
+ const setPromises = [];
222
+ locationKeyToHookSourceData.forEach(hookSourceData => {
223
+ if (hookSourceData.sourceConsumer != null) {
224
+ // Use cached source map consumer.
225
return;
226
}
227
235
}
236
} else {
237
for (let i = 0; i < sourceMappingURLs.length; i++) {
238
+ const {runtimeSourceURL} = hookSourceData;
239
const sourceMappingURL = sourceMappingURLs[i];
240
const index = sourceMappingURL.indexOf('base64,');
241
if (index >= 0) {
242
+ // TODO (named hooks) deduplicate parsing in this branch (similar to fetching in the other branch)
243
+ // since there can be multiple location keys per source map.
244
+
245
// Web apps like Code Sandbox embed multiple inline source maps.
246
// In this case, we need to loop through and find the right one.
247
// We may also need to trim any part of this string that isn't based64 encoded data.
261
262
// Hook source might be a URL like "https://4syus.csb.app/src/App.js"
263
// Parsed source map might be a partial path like "src/App.js"
214
- const fileName = ((hookSourceData.hookSource.fileName: any): string);
264
const match = parsed.sources.find(
265
source =>
217
- source === 'Inline Babel script' || fileName.includes(source),
266
+ source === 'Inline Babel script' ||
267
+ runtimeSourceURL.endsWith(source),
268
);
269
if (match) {
220
- hookSourceData.sourceMapContents = parsed;
270
+ setPromises.push(
271
+ new SourceMapConsumer(parsed).then(sourceConsumer => {
272
+ hookSourceData.sourceConsumer = sourceConsumer;
273
+ }),
274
+ );
275
break;
276
}
277
} else {
281
);
282
}
283
230
- let url = sourceMappingURLs[0].split('=')[1];
284
+ let url = sourceMappingURLs[i].split('=')[1];
285
if (ABSOLUTE_URL_REGEX.test(url)) {
286
const baseURL = url.slice(0, url.lastIndexOf('/'));
287
url = `${baseURL}/${url}`;
289
if (!isValidUrl(url)) {
290
throw new Error(`Invalid source map URL "${url}"`);
291
}
292
+ } else if (!url.startsWith('/')) {
293
+ // Resolve paths relative to the location of the file name
294
+ const lastSlashIdx = runtimeSourceURL.lastIndexOf('/');
295
+ if (lastSlashIdx !== -1) {
296
+ const baseURL = runtimeSourceURL.slice(
297
+ 0,
298
+ runtimeSourceURL.lastIndexOf('/'),
299
+ );
300
+ url = `${baseURL}/${url}`;
301
+ }
302
}
303
304
hookSourceData.sourceMapURL = url;
305
242
- if (__DEBUG__) {
243
- console.log(
244
- 'extractAndLoadSourceMaps() External source map "' + url + '"',
306
+ const fetchPromise =
307
+ fetchPromises.get(url) ||
308
+ fetchFile(url).then(
309
+ sourceMapContents =>
310
+ new SourceMapConsumer(JSON.parse(sourceMapContents)),
311
);
312
+ if (__DEBUG__) {
313
+ if (!fetchPromises.has(url)) {
314
+ console.log(
315
+ `extractAndLoadSourceMaps() External source map "${url}"`,
316
+ );
317
+ }
318
}
319
248
- promises.push(
249
- fetchFile(url).then(sourceMapContents => {
250
- hookSourceData.sourceMapContents = JSON.parse(sourceMapContents);
320
+ fetchPromises.set(url, fetchPromise);
321
+ setPromises.push(
322
+ fetchPromise.then(sourceConsumer => {
323
+ hookSourceData.sourceConsumer = sourceConsumer;
324
}),
325
);
326
break;
328
}
329
}
330
});
258
- return Promise.all(promises);
331
+ return Promise.all(setPromises);
332
}
333
334
function fetchFile(url: string): Promise<string> {
341
resolve(text);
342
})
343
.catch(error => {
344
+ if (__DEBUG__) {
345
+ console.log(`fetchFile() Could not read text for url "${url}"`);
346
+ }
347
reject(null);
348
});
349
} else {
350
+ if (__DEBUG__) {
351
+ console.log(`fetchFile() Got bad response for url "${url}"`);
352
+ }
353
reject(null);
354
}
355
});
358
359
function findHookNames(
360
hooksList: Array<HooksNode>,
282
- fileNameToHookSourceData: Map<string, HookSourceData>,
361
+ locationKeyToHookSourceData: Map<string, HookSourceData>,
362
): HookNames {
363
const map: HookNames = new Map();
364
365
hooksList.map(hook => {
366
+ // TODO (named hooks) We should probably filter before this point,
367
+ // otherwise we are loading and parsing source maps and ASTs for nothing.
368
if (isNonDeclarativePrimitiveHook(hook)) {
369
if (__DEBUG__) {
370
console.log('findHookNames() Non declarative primitive hook');
381
return null; // Should not be reachable.
382
}
383
303
- const hookSourceData = fileNameToHookSourceData.get(fileName);
384
+ const locationKey = getLocationKey(hookSource);
385
+ const hookSourceData = locationKeyToHookSourceData.get(locationKey);
386
if (!hookSourceData) {
387
return null; // Should not be reachable.
388
}
403
} else {
404
originalSourceLineNumber = sourceConsumer.originalPositionFor({
405
line: lineNumber,
324
- column: columnNumber,
406
+
407
+ // Column numbers are representated differently between tools/engines.
408
+ // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
409
+ column: columnNumber - 1,
410
}).line;
411
}
412
431
);
432
433
if (__DEBUG__) {
349
- console.log('findHookNames() Found name "' + (name || '-') + '"');
434
+ console.log(`findHookNames() Found name "${name || '-'}"`);
435
}
436
437
map.set(hook, name);
451
}
452
453
function loadSourceFiles(
369
- fileNameToHookSourceData: Map<string, HookSourceData>,
454
+ locationKeyToHookSourceData: Map<string, HookSourceData>,
455
): Promise<*> {
371
- const promises = [];
372
- fileNameToHookSourceData.forEach((hookSourceData, fileName) => {
373
- promises.push(
374
- fetchFile(fileName).then(runtimeSourceCode => {
456
+ // Deduplicate fetches, since there can be multiple location keys per file.
457
+ const fetchPromises = new Map();
458
+
459
+ const setPromises = [];
460
+ locationKeyToHookSourceData.forEach(hookSourceData => {
461
+ const {runtimeSourceURL} = hookSourceData;
462
+ const fetchPromise =
463
+ fetchPromises.get(runtimeSourceURL) ||
464
+ fetchFile(runtimeSourceURL).then(runtimeSourceCode => {
465
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
466
throw Error('Source code too large to parse');
467
}
378
-
468
if (__DEBUG__) {
469
console.groupCollapsed(
381
- 'loadSourceFiles() fileName "' + fileName + '"',
470
+ `loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
471
);
472
console.log(runtimeSourceCode);
473
console.groupEnd();
474
}
386
-
475
+ return runtimeSourceCode;
476
+ });
477
+ fetchPromises.set(runtimeSourceURL, fetchPromise);
478
+ setPromises.push(
479
+ fetchPromise.then(runtimeSourceCode => {
480
hookSourceData.runtimeSourceCode = runtimeSourceCode;
481
}),
482
);
483
});
391
- return Promise.all(promises);
484
+ return Promise.all(setPromises);
485
}
486
487
async function parseSourceAST(
395
- fileNameToHookSourceData: Map<string, HookSourceData>,
488
+ locationKeyToHookSourceData: Map<string, HookSourceData>,
489
): Promise<*> {
397
- // SourceMapConsumer.initialize() does nothing when running in Node (aka Jest)
398
- // because the wasm file is automatically read from the file system
399
- // so we can avoid triggering a warning message about this.
400
- if (!__TEST__) {
401
- if (__DEBUG__) {
402
- console.log('parseSourceAST() Initializing source-map library ...');
403
- }
404
-
405
- // $FlowFixMe
406
- const wasmMappingsURL = chrome.extension.getURL('mappings.wasm');
407
-
408
- SourceMapConsumer.initialize({'lib/mappings.wasm': wasmMappingsURL});
409
- }
410
-
411
- const promises = [];
412
- fileNameToHookSourceData.forEach(hookSourceData => {
490
+ locationKeyToHookSourceData.forEach(hookSourceData => {
491
if (hookSourceData.originalSourceAST !== null) {
492
// Use cached metadata.
493
return;
494
}
495
418
- const {runtimeSourceCode, sourceMapContents} = hookSourceData;
419
- if (sourceMapContents !== null) {
496
+ const {sourceConsumer} = hookSourceData;
497
+ const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string);
498
+ let originalSourceURL, originalSourceCode;
499
+ if (sourceConsumer !== null) {
500
// Parse and extract the AST from the source map.
421
- promises.push(
422
- SourceMapConsumer.with(
423
- sourceMapContents,
424
- null,
425
- (sourceConsumer: SourceConsumer) => {
426
- hookSourceData.sourceConsumer = sourceConsumer;
427
-
428
- // Now that the source map has been loaded,
429
- // extract the original source for later.
430
- const source = sourceMapContents.sources[0];
431
- const originalSourceCode = sourceConsumer.sourceContentFor(
432
- source,
433
- true,
434
- );
501
+ const {lineNumber, columnNumber} = hookSourceData.hookSource;
502
+ if (lineNumber == null || columnNumber == null) {
503
+ throw Error('Hook source code location not found.');
504
+ }
505
+ // Now that the source map has been loaded,
506
+ // extract the original source for later.
507
+ const {source} = sourceConsumer.originalPositionFor({
508
+ line: lineNumber,
509
436
- if (__DEBUG__) {
437
- console.groupCollapsed(
438
- 'parseSourceAST() Extracted source code from source map',
439
- );
440
- console.log(originalSourceCode);
441
- console.groupEnd();
442
- }
510
+ // Column numbers are representated differently between tools/engines.
511
+ // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
512
+ column: columnNumber - 1,
513
+ });
514
+
515
+ if (source == null) {
516
+ // TODO (named hooks) maybe fall back to the runtime source instead of throwing?
517
+ throw new Error(
518
+ 'Could not map hook runtime location to original source location',
519
+ );
520
+ }
521
444
- hookSourceData.originalSourceCode = originalSourceCode;
522
+ // TODO (named hooks) maybe canonicalize this URL somehow?
523
+ // It can be relative if the source map specifies it that way,
524
+ // but we use it as a cache key across different source maps and there can be collisions.
525
+ originalSourceURL = (source: string);
526
+ originalSourceCode = (sourceConsumer.sourceContentFor(
527
+ source,
528
+ true,
529
+ ): string);
530
446
- // TODO Parsing should ideally be done off of the main thread.
447
- hookSourceData.originalSourceAST = parse(originalSourceCode, {
448
- sourceType: 'unambiguous',
449
- plugins: ['jsx', 'typescript'],
450
- });
451
- },
452
- ),
453
- );
531
+ if (__DEBUG__) {
532
+ console.groupCollapsed(
533
+ 'parseSourceAST() Extracted source code from source map',
534
+ );
535
+ console.log(originalSourceCode);
536
+ console.groupEnd();
537
+ }
538
} else {
539
// There's no source map to parse here so we can just parse the original source itself.
456
- hookSourceData.originalSourceCode = runtimeSourceCode;
540
+ originalSourceCode = runtimeSourceCode;
541
+ // TODO (named hooks) This mixes runtimeSourceURLs with source mapped URLs in the same cache key space.
542
+ // Namespace them?
543
+ originalSourceURL = hookSourceData.runtimeSourceURL;
544
+ }
545
+
546
+ hookSourceData.originalSourceCode = originalSourceCode;
547
+ hookSourceData.originalSourceURL = originalSourceURL;
548
458
- // TODO Parsing should ideally be done off of the main thread.
459
- hookSourceData.originalSourceAST = parse(runtimeSourceCode, {
549
+ // The cache also serves to deduplicate parsing by URL in our loop over
550
+ // location keys. This may need to change if we switch to async parsing.
551
+ const sourceMetadata = originalURLToMetadataCache.get(originalSourceURL);
552
+ if (sourceMetadata != null) {
553
+ if (__DEBUG__) {
554
+ console.groupCollapsed(
555
+ `parseSourceAST() Found cached source metadata for "${originalSourceURL}"`,
556
+ );
557
+ console.log(sourceMetadata);
558
+ console.groupEnd();
559
+ }
560
+ hookSourceData.originalSourceAST = sourceMetadata.originalSourceAST;
561
+ hookSourceData.originalSourceCode = sourceMetadata.originalSourceCode;
562
+ } else {
563
+ // TODO (named hooks) Parsing should ideally be done off of the main thread.
564
+ const originalSourceAST = parse(originalSourceCode, {
565
sourceType: 'unambiguous',
566
plugins: ['jsx', 'typescript'],
567
});
568
+ hookSourceData.originalSourceAST = originalSourceAST;
569
+ if (__DEBUG__) {
570
+ console.log(
571
+ `parseSourceAST() Caching source metadata for "${originalSourceURL}"`,
572
+ );
573
+ }
574
+ originalURLToMetadataCache.set(originalSourceURL, {
575
+ originalSourceAST,
576
+ originalSourceCode,
577
+ });
578
}
579
});
465
- return Promise.all(promises);
580
+ return Promise.resolve();
581
}
582
583
function flattenHooksList(
594
}
595
596
function updateLruCache(
482
- fileNameToHookSourceData: Map<string, HookSourceData>,
597
+ locationKeyToHookSourceData: Map<string, HookSourceData>,
598
): Promise<*> {
484
- fileNameToHookSourceData.forEach(
485
- ({originalSourceAST, originalSourceCode, sourceConsumer}, fileName) => {
486
- // Only set once to avoid triggering eviction/cleanup code.
487
- if (!fileNameToMetadataCache.has(fileName)) {
488
- if (__DEBUG__) {
489
- console.log('updateLruCache() Caching metada for "' + fileName + '"');
490
- }
491
-
492
- fileNameToMetadataCache.set(fileName, {
493
- originalSourceAST,
494
- originalSourceCode: ((originalSourceCode: any): string),
495
- sourceConsumer,
496
- });
599
+ locationKeyToHookSourceData.forEach(({sourceConsumer, runtimeSourceURL}) => {
600
+ // Only set once to avoid triggering eviction/cleanup code.
601
+ if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) {
602
+ if (__DEBUG__) {
603
+ console.log(
604
+ `updateLruCache() Caching runtime metadata for "${runtimeSourceURL}"`,
605
+ );
606
}
498
- },
499
- );
607
+
608
+ runtimeURLToMetadataCache.set(runtimeSourceURL, {
609
+ sourceConsumer,
610
+ });
611
+ }
612
+ });
613
return Promise.resolve();
614
}