Skip to content

Commit

Permalink
DevTools: Hook names optimizations (#22403)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Brian Vaughn authored Sep 23, 2021
1 parent 95502f7 commit d174d06
Show file tree
Hide file tree
Showing 3 changed files with 303 additions and 111 deletions.
241 changes: 241 additions & 0 deletions packages/react-devtools-shared/src/hooks/SourceMapConsumer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {withSyncPerfMeasurements} from 'react-devtools-shared/src/PerformanceLoggingUtils';
import {decode} from 'sourcemap-codec';

import type {
IndexSourceMap,
BasicSourceMap,
MixedSourceMap,
} from './SourceMapTypes';

type SearchPosition = {|
columnNumber: number,
lineNumber: number,
|};

type ResultPosition = {|
column: number,
line: number,
sourceContent: string,
sourceURL: string,
|};

export type SourceMapConsumerType = {|
originalPositionFor: SearchPosition => ResultPosition,
|};

type Mappings = Array<Array<Array<number>>>;

export default function SourceMapConsumer(
sourceMapJSON: MixedSourceMap,
): SourceMapConsumerType {
if (sourceMapJSON.sections != null) {
return IndexedSourceMapConsumer(((sourceMapJSON: any): IndexSourceMap));
} else {
return BasicSourceMapConsumer(((sourceMapJSON: any): BasicSourceMap));
}
}

function BasicSourceMapConsumer(sourceMapJSON: BasicSourceMap) {
const decodedMappings: Mappings = withSyncPerfMeasurements(
'Decoding source map mappings with sourcemap-codec',
() => decode(sourceMapJSON.mappings),
);

function originalPositionFor({
columnNumber,
lineNumber,
}: SearchPosition): ResultPosition {
// Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
const targetColumnNumber = columnNumber - 1;

const lineMappings = decodedMappings[lineNumber - 1];

let nearestEntry = null;

let startIndex = 0;
let stopIndex = lineMappings.length - 1;
let index = -1;
while (startIndex <= stopIndex) {
index = Math.floor((stopIndex + startIndex) / 2);
nearestEntry = lineMappings[index];

const currentColumn = nearestEntry[0];
if (currentColumn === targetColumnNumber) {
break;
} else {
if (currentColumn > targetColumnNumber) {
if (stopIndex - index > 0) {
stopIndex = index;
} else {
index = stopIndex;
break;
}
} else {
if (index - startIndex > 0) {
startIndex = index;
} else {
index = startIndex;
break;
}
}
}
}

// We have found either the exact element, or the next-closest element.
// However there may be more than one such element.
// Make sure we always return the smallest of these.
while (index > 0) {
const previousEntry = lineMappings[index - 1];
const currentColumn = previousEntry[0];
if (currentColumn !== targetColumnNumber) {
break;
}
index--;
}

if (nearestEntry == null) {
// TODO maybe fall back to the runtime source instead of throwing?
throw Error(
`Could not find runtime location for line:${lineNumber} and column:${columnNumber}`,
);
}

const sourceIndex = nearestEntry[1];
const sourceContent =
sourceMapJSON.sourcesContent != null
? sourceMapJSON.sourcesContent[sourceIndex]
: null;
const sourceURL = sourceMapJSON.sources[sourceIndex] ?? null;
const line = nearestEntry[2] + 1;
const column = nearestEntry[3];

if (sourceContent === null || sourceURL === null) {
// TODO maybe fall back to the runtime source instead of throwing?
throw Error(
`Could not find original source for line:${lineNumber} and column:${columnNumber}`,
);
}

return {
column,
line,
sourceContent: ((sourceContent: any): string),
sourceURL: ((sourceURL: any): string),
};
}

return (({
originalPositionFor,
}: any): SourceMapConsumerType);
}

function IndexedSourceMapConsumer(sourceMapJSON: IndexSourceMap) {
let lastOffset = {
line: -1,
column: 0,
};

const sections = sourceMapJSON.sections.map(section => {
const offset = section.offset;
const offsetLine = offset.line;
const offsetColumn = offset.column;

if (
offsetLine < lastOffset.line ||
(offsetLine === lastOffset.line && offsetColumn < lastOffset.column)
) {
throw new Error('Section offsets must be ordered and non-overlapping.');
}

lastOffset = offset;

return {
// The offset fields are 0-based, but we use 1-based indices when encoding/decoding from VLQ.
generatedLine: offsetLine + 1,
generatedColumn: offsetColumn + 1,
sourceMapConsumer: new SourceMapConsumer(section.map),
};
});

function originalPositionFor({
columnNumber,
lineNumber,
}: SearchPosition): ResultPosition {
// Error.prototype.stack columns are 1-based (like most IDEs) but ASTs are 0-based.
const targetColumnNumber = columnNumber - 1;

let section = null;

let startIndex = 0;
let stopIndex = sections.length - 1;
let index = -1;
while (startIndex <= stopIndex) {
index = Math.floor((stopIndex + startIndex) / 2);
section = sections[index];

const currentLine = section.generatedLine;
if (currentLine === lineNumber) {
const currentColumn = section.generatedColumn;
if (currentColumn === lineNumber) {
break;
} else {
if (currentColumn > targetColumnNumber) {
if (stopIndex - index > 0) {
stopIndex = index;
} else {
index = stopIndex;
break;
}
} else {
if (index - startIndex > 0) {
startIndex = index;
} else {
index = startIndex;
break;
}
}
}
} else {
if (currentLine > lineNumber) {
if (stopIndex - index > 0) {
stopIndex = index;
} else {
index = stopIndex;
break;
}
} else {
if (index - startIndex > 0) {
startIndex = index;
} else {
index = startIndex;
break;
}
}
}
}

if (section == null) {
// TODO maybe fall back to the runtime source instead of throwing?
throw Error(
`Could not find matching section for line:${lineNumber} and column:${columnNumber}`,
);
}

return section.sourceMapConsumer.originalPositionFor({
columnNumber,
lineNumber,
});
}

return (({
originalPositionFor,
}: any): SourceMapConsumerType);
}
2 changes: 0 additions & 2 deletions packages/react-devtools-shared/src/hooks/astUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ export type Position = {|
column: number,
|};

export type SourceConsumer = any;

export type SourceFileASTWithHookDetails = {
sourceFileAST: File,
line: number,
Expand Down
Loading

0 comments on commit d174d06

Please sign in to comment.