main
js 520 lines 18.7 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 // 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 }