@samitouri / QOS-React-2 / commits / d174d063d1

DevTools: Hook names optimizations (#22403)

This commit dramatically improves the performance of the hook names feature by replacing the source-map-js integration with custom mapping code built on top of sourcemap-codec. Based on my own benchmarking, this makes parsing 3-4 times faster. (The bulk of these changes are in SourceMapConsumer.js.) While implementing this code, I also uncovered a problem with the way we were caching source-map metadata that was causing us to potential parse the same source-map multiple times. (I addressed this in a separate commit for easier reviewing. The bulk of these changes are in parseSourceAndMetadata.js.) Altogether these changes dramatically improve the performance of the hooks parsing code. One additional thing we could look into if the source-map download still remains a large bottleneck would be to stream it and decode the mappings array while it streams in rather than in one synchronous chunk after the full source-map has been downloaded.

Brian Vaughn committed Sep 22, 2021 at 20:17 UTC d174d063d13d428ad4002474cb560464a7f70f45
3 files changed +303 -111
packages/react-devtools-shared/src/hooks/SourceMapConsumer.js new
+241
@@ -0,0 +1,241 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +import {withSyncPerfMeasurements} from 'react-devtools-shared/src/PerformanceLoggingUtils';
10 +import {decode} from 'sourcemap-codec';
11 +
12 +import type {
13 + IndexSourceMap,
14 + BasicSourceMap,
15 + MixedSourceMap,
16 +} from './SourceMapTypes';
17 +
18 +type SearchPosition = {|
19 + columnNumber: number,
20 + lineNumber: number,
21 +|};
22 +
23 +type ResultPosition = {|
24 + column: number,
25 + line: number,
26 + sourceContent: string,
27 + sourceURL: string,
28 +|};
29 +
30 +export type SourceMapConsumerType = {|
31 + originalPositionFor: SearchPosition => ResultPosition,
32 +|};
33 +
34 +type Mappings = Array<Array<Array<number>>>;
35 +
36 +export default function SourceMapConsumer(
37 + sourceMapJSON: MixedSourceMap,
38 +): SourceMapConsumerType {
39 + if (sourceMapJSON.sections != null) {
40 + return IndexedSourceMapConsumer(((sourceMapJSON: any): IndexSourceMap));
41 + } else {
42 + return BasicSourceMapConsumer(((sourceMapJSON: any): BasicSourceMap));
43 + }
44 +}
45 +
46 +function BasicSourceMapConsumer(sourceMapJSON: BasicSourceMap) {
47 + const decodedMappings: Mappings = withSyncPerfMeasurements(
48 + 'Decoding source map mappings with sourcemap-codec',
49 + () => decode(sourceMapJSON.mappings),
50 + );
51 +
52 + function originalPositionFor({
53 + columnNumber,
54 + lineNumber,
55 + }: SearchPosition): ResultPosition {
56 + // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
57 + const targetColumnNumber = columnNumber - 1;
58 +
59 + const lineMappings = decodedMappings[lineNumber - 1];
60 +
61 + let nearestEntry = null;
62 +
63 + let startIndex = 0;
64 + let stopIndex = lineMappings.length - 1;
65 + let index = -1;
66 + while (startIndex <= stopIndex) {
67 + index = Math.floor((stopIndex + startIndex) / 2);
68 + nearestEntry = lineMappings[index];
69 +
70 + const currentColumn = nearestEntry[0];
71 + if (currentColumn === targetColumnNumber) {
72 + break;
73 + } else {
74 + if (currentColumn > targetColumnNumber) {
75 + if (stopIndex - index > 0) {
76 + stopIndex = index;
77 + } else {
78 + index = stopIndex;
79 + break;
80 + }
81 + } else {
82 + if (index - startIndex > 0) {
83 + startIndex = index;
84 + } else {
85 + index = startIndex;
86 + break;
87 + }
88 + }
89 + }
90 + }
91 +
92 + // We have found either the exact element, or the next-closest element.
93 + // However there may be more than one such element.
94 + // Make sure we always return the smallest of these.
95 + while (index > 0) {
96 + const previousEntry = lineMappings[index - 1];
97 + const currentColumn = previousEntry[0];
98 + if (currentColumn !== targetColumnNumber) {
99 + break;
100 + }
101 + index--;
102 + }
103 +
104 + if (nearestEntry == null) {
105 + // TODO maybe fall back to the runtime source instead of throwing?
106 + throw Error(
107 + `Could not find runtime location for line:${lineNumber} and column:${columnNumber}`,
108 + );
109 + }
110 +
111 + const sourceIndex = nearestEntry[1];
112 + const sourceContent =
113 + sourceMapJSON.sourcesContent != null
114 + ? sourceMapJSON.sourcesContent[sourceIndex]
115 + : null;
116 + const sourceURL = sourceMapJSON.sources[sourceIndex] ?? null;
117 + const line = nearestEntry[2] + 1;
118 + const column = nearestEntry[3];
119 +
120 + if (sourceContent === null || sourceURL === null) {
121 + // TODO maybe fall back to the runtime source instead of throwing?
122 + throw Error(
123 + `Could not find original source for line:${lineNumber} and column:${columnNumber}`,
124 + );
125 + }
126 +
127 + return {
128 + column,
129 + line,
130 + sourceContent: ((sourceContent: any): string),
131 + sourceURL: ((sourceURL: any): string),
132 + };
133 + }
134 +
135 + return (({
136 + originalPositionFor,
137 + }: any): SourceMapConsumerType);
138 +}
139 +
140 +function IndexedSourceMapConsumer(sourceMapJSON: IndexSourceMap) {
141 + let lastOffset = {
142 + line: -1,
143 + column: 0,
144 + };
145 +
146 + const sections = sourceMapJSON.sections.map(section => {
147 + const offset = section.offset;
148 + const offsetLine = offset.line;
149 + const offsetColumn = offset.column;
150 +
151 + if (
152 + offsetLine < lastOffset.line ||
153 + (offsetLine === lastOffset.line && offsetColumn < lastOffset.column)
154 + ) {
155 + throw new Error('Section offsets must be ordered and non-overlapping.');
156 + }
157 +
158 + lastOffset = offset;
159 +
160 + return {
161 + // The offset fields are 0-based, but we use 1-based indices when encoding/decoding from VLQ.
162 + generatedLine: offsetLine + 1,
163 + generatedColumn: offsetColumn + 1,
164 + sourceMapConsumer: new SourceMapConsumer(section.map),
165 + };
166 + });
167 +
168 + function originalPositionFor({
169 + columnNumber,
170 + lineNumber,
171 + }: SearchPosition): ResultPosition {
172 + // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
173 + const targetColumnNumber = columnNumber - 1;
174 +
175 + let section = null;
176 +
177 + let startIndex = 0;
178 + let stopIndex = sections.length - 1;
179 + let index = -1;
180 + while (startIndex <= stopIndex) {
181 + index = Math.floor((stopIndex + startIndex) / 2);
182 + section = sections[index];
183 +
184 + const currentLine = section.generatedLine;
185 + if (currentLine === lineNumber) {
186 + const currentColumn = section.generatedColumn;
187 + if (currentColumn === lineNumber) {
188 + break;
189 + } else {
190 + if (currentColumn > targetColumnNumber) {
191 + if (stopIndex - index > 0) {
192 + stopIndex = index;
193 + } else {
194 + index = stopIndex;
195 + break;
196 + }
197 + } else {
198 + if (index - startIndex > 0) {
199 + startIndex = index;
200 + } else {
201 + index = startIndex;
202 + break;
203 + }
204 + }
205 + }
206 + } else {
207 + if (currentLine > lineNumber) {
208 + if (stopIndex - index > 0) {
209 + stopIndex = index;
210 + } else {
211 + index = stopIndex;
212 + break;
213 + }
214 + } else {
215 + if (index - startIndex > 0) {
216 + startIndex = index;
217 + } else {
218 + index = startIndex;
219 + break;
220 + }
221 + }
222 + }
223 + }
224 +
225 + if (section == null) {
226 + // TODO maybe fall back to the runtime source instead of throwing?
227 + throw Error(
228 + `Could not find matching section for line:${lineNumber} and column:${columnNumber}`,
229 + );
230 + }
231 +
232 + return section.sourceMapConsumer.originalPositionFor({
233 + columnNumber,
234 + lineNumber,
235 + });
236 + }
237 +
238 + return (({
239 + originalPositionFor,
240 + }: any): SourceMapConsumerType);
241 +}
packages/react-devtools-shared/src/hooks/astUtils.js
-2
@@ -18,8 +18,6 @@ export type Position = {|
18 column: number,
19 |};
20
21 -export type SourceConsumer = any;
22 -
21 export type SourceFileASTWithHookDetails = {
22 sourceFileAST: File,
23 line: number,
packages/react-devtools-shared/src/hooks/parseHookNames/parseSourceAndMetadata.js
+62 -109
@@ -12,7 +12,6 @@
12
13 import {parse} from '@babel/parser';
14 import LRU from 'lru-cache';
15 -import {SourceMapConsumer} from 'source-map-js';
15 import {getHookName} from '../astUtils';
16 import {areSourceMapsAppliedToErrors} from '../ErrorTester';
17 import {__DEBUG__} from 'react-devtools-shared/src/constants';
@@ -22,14 +21,15 @@ 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 {HookNames, LRUCache} from 'react-devtools-shared/src/types';
32 -import type {SourceConsumer} from '../astUtils';
33
34 type AST = mixed;
35
@@ -53,35 +53,21 @@ type HookParsedMetadata = {|
53 // Column number in original source code.
54 originalSourceColumnNumber: number | null,
55
56 - // APIs from source-map for parsing source maps (if detected).
57 - sourceConsumer: SourceConsumer | null,
56 + // Alternate APIs from source-map for parsing source maps (if detected).
57 + sourceMapConsumer: SourceMapConsumerType | null,
58 |};
59
60 type LocationKeyToHookParsedMetadata = Map<string, HookParsedMetadata>;
61
62 type CachedRuntimeCodeMetadata = {|
63 - sourceConsumer: SourceConsumer | null,
63 metadataConsumer: SourceMapMetadataConsumer | null,
64 + sourceMapConsumer: SourceMapConsumerType | null,
65 |};
66
67 const runtimeURLToMetadataCache: LRUCache<
68 string,
69 CachedRuntimeCodeMetadata,
70 -> = new LRU({
71 - max: 50,
72 - dispose: (runtimeSourceURL: string, metadata: CachedRuntimeCodeMetadata) => {
73 - if (__DEBUG__) {
74 - console.log(
75 - `runtimeURLToMetadataCache.dispose() Evicting cached metadata for "${runtimeSourceURL}"`,
76 - );
77 - }
78 -
79 - const sourceConsumer = metadata.sourceConsumer;
80 - if (sourceConsumer !== null) {
81 - sourceConsumer.destroy();
82 - }
83 - },
84 -});
70 +> = new LRU({max: 50});
71
72 type CachedSourceCodeMetadata = {|
73 originalSourceAST: AST,
@@ -220,27 +206,10 @@ function initializeHookParsedMetadata(
206 originalSourceURL: null,
207 originalSourceLineNumber: null,
208 originalSourceColumnNumber: null,
223 - sourceConsumer: null,
209 + sourceMapConsumer: null,
210 };
211
212 locationKeyToHookParsedMetadata.set(locationKey, hookParsedMetadata);
227 -
228 - const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL;
229 -
230 - // If we've already loaded the source map info for this file,
231 - // we can skip reloading it (and more importantly, re-parsing it).
232 - const runtimeMetadata = runtimeURLToMetadataCache.get(runtimeSourceURL);
233 - if (runtimeMetadata != null) {
234 - if (__DEBUG__) {
235 - console.groupCollapsed(
236 - `parseHookNames() Found cached runtime metadata for file "${runtimeSourceURL}"`,
237 - );
238 - console.log(runtimeMetadata);
239 - console.groupEnd();
240 - }
241 - hookParsedMetadata.sourceConsumer = runtimeMetadata.sourceConsumer;
242 - hookParsedMetadata.metadataConsumer = runtimeMetadata.metadataConsumer;
243 - }
213 },
214 );
215
@@ -280,14 +249,14 @@ function parseSourceAST(
249 throw Error('Hook source code location not found.');
250 }
251
283 - const {metadataConsumer, sourceConsumer} = hookParsedMetadata;
252 + const {metadataConsumer, sourceMapConsumer} = hookParsedMetadata;
253 const runtimeSourceCode = ((hookSourceAndMetadata.runtimeSourceCode: any): string);
254 let hasHookMap = false;
255 let originalSourceURL;
256 let originalSourceCode;
257 let originalSourceColumnNumber;
258 let originalSourceLineNumber;
290 - if (areSourceMapsAppliedToErrors() || sourceConsumer == null) {
259 + if (areSourceMapsAppliedToErrors() || sourceMapConsumer === null) {
260 // Either the current environment automatically applies source maps to errors,
261 // or the current code had no source map to begin with.
262 // Either way, we don't need to convert the Error stack frame locations.
@@ -299,55 +268,32 @@ function parseSourceAST(
268 // Namespace them?
269 originalSourceURL = hookSourceAndMetadata.runtimeSourceURL;
270 } else {
302 - // Parse and extract the AST from the source map.
303 - // Now that the source map has been loaded,
304 - // extract the original source for later.
305 - // TODO (named hooks) Refactor this read, github.com/facebook/react/pull/22181
306 - const {column, line, source} = withSyncPerfMeasurements(
307 - 'sourceConsumer.originalPositionFor()',
308 - () =>
309 - sourceConsumer.originalPositionFor({
310 - line: lineNumber,
311 -
312 - // Column numbers are represented differently between tools/engines.
313 - // Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
314 - // For more info see https://github.com/facebook/react/issues/21792#issuecomment-873171991
315 - column: columnNumber - 1,
316 - }),
317 - );
318 -
319 - if (source == null) {
320 - // TODO (named hooks) maybe fall back to the runtime source instead of throwing?
321 - throw new Error(
322 - 'Could not map hook runtime location to original source location',
323 - );
324 - }
271 + const {
272 + column,
273 + line,
274 + sourceContent,
275 + sourceURL,
276 + } = sourceMapConsumer.originalPositionFor({
277 + columnNumber,
278 + lineNumber,
279 + });
280
281 originalSourceColumnNumber = column;
282 originalSourceLineNumber = line;
328 - // TODO (named hooks) maybe canonicalize this URL somehow?
329 - // It can be relative if the source map specifies it that way,
330 - // but we use it as a cache key across different source maps and there can be collisions.
331 - originalSourceURL = (source: string);
332 - originalSourceCode = withSyncPerfMeasurements(
333 - 'sourceConsumer.sourceContentFor()',
334 - () => (sourceConsumer.sourceContentFor(source, true): string),
335 - );
283 + originalSourceCode = sourceContent;
284 + originalSourceURL = sourceURL;
285 + }
286
337 - if (__DEBUG__) {
338 - console.groupCollapsed(
339 - `parseSourceAST() Extracted source code from source map for "${originalSourceURL}"`,
340 - );
341 - console.log(originalSourceCode);
342 - console.groupEnd();
343 - }
287 + hookParsedMetadata.originalSourceCode = originalSourceCode;
288 + hookParsedMetadata.originalSourceURL = originalSourceURL;
289 + hookParsedMetadata.originalSourceLineNumber = originalSourceLineNumber;
290 + hookParsedMetadata.originalSourceColumnNumber = originalSourceColumnNumber;
291
345 - if (
346 - metadataConsumer != null &&
347 - metadataConsumer.hasHookMap(originalSourceURL)
348 - ) {
349 - hasHookMap = true;
350 - }
292 + if (
293 + metadataConsumer != null &&
294 + metadataConsumer.hasHookMap(originalSourceURL)
295 + ) {
296 + hasHookMap = true;
297 }
298
299 if (__DEBUG__) {
@@ -356,11 +302,6 @@ function parseSourceAST(
302 );
303 }
304
359 - hookParsedMetadata.originalSourceCode = originalSourceCode;
360 - hookParsedMetadata.originalSourceURL = originalSourceURL;
361 - hookParsedMetadata.originalSourceLineNumber = originalSourceLineNumber;
362 - hookParsedMetadata.originalSourceColumnNumber = originalSourceColumnNumber;
363 -
305 if (hasHookMap) {
306 if (__DEBUG__) {
307 console.log(
@@ -447,30 +388,42 @@ function parseSourceMaps(
388 throw Error(`Expected to find HookParsedMetadata for "${locationKey}"`);
389 }
390
450 - const sourceMapJSON = hookSourceAndMetadata.sourceMapJSON;
451 - if (sourceMapJSON != null) {
452 - hookParsedMetadata.metadataConsumer = withSyncPerfMeasurements(
453 - 'new SourceMapMetadataConsumer(sourceMapJSON)',
454 - () => new SourceMapMetadataConsumer(sourceMapJSON),
455 - );
456 - hookParsedMetadata.sourceConsumer = withSyncPerfMeasurements(
457 - 'new SourceMapConsumer(sourceMapJSON)',
458 - () => new SourceMapConsumer(sourceMapJSON),
459 - );
391 + const {runtimeSourceURL, sourceMapJSON} = hookSourceAndMetadata;
392 +
393 + // If we've already loaded the source map info for this file,
394 + // we can skip reloading it (and more importantly, re-parsing it).
395 + const runtimeMetadata = runtimeURLToMetadataCache.get(runtimeSourceURL);
396 + if (runtimeMetadata != null) {
397 + if (__DEBUG__) {
398 + console.groupCollapsed(
399 + `parseHookNames() Found cached runtime metadata for file "${runtimeSourceURL}"`,
400 + );
401 + console.log(runtimeMetadata);
402 + console.groupEnd();
403 + }
404
461 - const runtimeSourceURL = hookSourceAndMetadata.runtimeSourceURL;
405 + hookParsedMetadata.metadataConsumer = runtimeMetadata.metadataConsumer;
406 + hookParsedMetadata.sourceMapConsumer =
407 + runtimeMetadata.sourceMapConsumer;
408 + } else {
409 + if (sourceMapJSON != null) {
410 + const sourceMapConsumer = withSyncPerfMeasurements(
411 + 'new SourceMapConsumer(sourceMapJSON)',
412 + () => SourceMapConsumer(sourceMapJSON),
413 + );
414
463 - // Only set once to avoid triggering eviction/cleanup code.
464 - if (!runtimeURLToMetadataCache.has(runtimeSourceURL)) {
465 - if (__DEBUG__) {
466 - console.log(
467 - `parseSourceMaps() Caching runtime metadata for "${runtimeSourceURL}"`,
468 - );
469 - }
415 + const metadataConsumer = withSyncPerfMeasurements(
416 + 'new SourceMapMetadataConsumer(sourceMapJSON)',
417 + () => new SourceMapMetadataConsumer(sourceMapJSON),
418 + );
419 +
420 + hookParsedMetadata.metadataConsumer = metadataConsumer;
421 + hookParsedMetadata.sourceMapConsumer = sourceMapConsumer;
422
423 + // Only set once to avoid triggering eviction/cleanup code.
424 runtimeURLToMetadataCache.set(runtimeSourceURL, {
472 - metadataConsumer: hookParsedMetadata.metadataConsumer,
473 - sourceConsumer: hookParsedMetadata.sourceConsumer,
425 + metadataConsumer: metadataConsumer,
426 + sourceMapConsumer: sourceMapConsumer,
427 });
428 }
429 }