main
js 449 lines 15.1 KB
Raw
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 }