Skip to content

Commit 750bf77

Browse files
author
Brian Vaughn
committed
Account for Error stack frames having source maps already applied
Refactored code to account for the fact that, in some environments, Error stack frames will already have source maps applied (so the code does not need to re-map line and column numbers).
1 parent a0de8da commit 750bf77

File tree

8 files changed

+162
-83
lines changed

8 files changed

+162
-83
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ packages/react-devtools-extensions/chrome/build
1818
packages/react-devtools-extensions/firefox/build
1919
packages/react-devtools-extensions/shared/build
2020
packages/react-devtools-extensions/src/__tests__/__source__/__compiled__/
21+
packages/react-devtools-extensions/src/ErrorTesterCompiled.js
2122
packages/react-devtools-inline/dist
2223
packages/react-devtools-shell/dist
2324
packages/react-devtools-scheduling-profiler/dist

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ packages/react-devtools-extensions/chrome/build
33
packages/react-devtools-extensions/firefox/build
44
packages/react-devtools-extensions/shared/build
55
packages/react-devtools-extensions/src/__tests__/__source__/__compiled__/
6+
packages/react-devtools-extensions/src/ErrorTesterCompiled.js
67
packages/react-devtools-inline/dist
78
packages/react-devtools-shell/dist
89
packages/react-devtools-scheduling-profiler/dist
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
10+
import ErrorStackParser from 'error-stack-parser';
11+
import testErrorStack, {
12+
SOURCE_STACK_FRAME_LINE_NUMBER,
13+
} from './ErrorTesterCompiled';
14+
15+
// Source maps are automatically applied to Error stack frames.
16+
export let sourceMapsAreAppliedToErrors: boolean = false;
17+
18+
try {
19+
testErrorStack();
20+
} catch (error) {
21+
const parsed = ErrorStackParser.parse(error);
22+
const topStackFrame = parsed[0];
23+
const lineNumber = topStackFrame.lineNumber;
24+
if (lineNumber === SOURCE_STACK_FRAME_LINE_NUMBER) {
25+
sourceMapsAreAppliedToErrors = true;
26+
}
27+
}

packages/react-devtools-extensions/src/ErrorTesterCompiled.js

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-devtools-extensions/src/__tests__/parseHookNames-test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ describe('parseHookNames', () => {
1515
beforeEach(() => {
1616
jest.resetModules();
1717

18+
jest.mock('source-map-support', () => {
19+
console.trace('source-map-support');
20+
});
21+
1822
const {
1923
overrideFeatureFlags,
2024
} = require('react-devtools-shared/src/__tests__/utils');

packages/react-devtools-extensions/src/astUtils.js

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
*/
99

1010
import traverse, {NodePath, Node} from '@babel/traverse';
11-
import {parse} from '@babel/parser';
1211
import {File} from '@babel/types';
1312

1413
import type {HooksNode} from 'react-debug-tools/src/ReactDebugHooks';
@@ -21,6 +20,14 @@ export type SourceFileASTWithHookDetails = {
2120
source: string,
2221
};
2322

23+
export type SourceMap = {|
24+
mappings: string,
25+
names: Array<string>,
26+
sources: Array<string>,
27+
sourcesContent: Array<string>,
28+
version: number,
29+
|};
30+
2431
const AST_NODE_TYPES = Object.freeze({
2532
CALL_EXPRESSION: 'CallExpression',
2633
MEMBER_EXPRESSION: 'MemberExpression',
@@ -56,37 +63,18 @@ export function filterMemberWithHookVariableName(hook: NodePath): boolean {
5663
);
5764
}
5865

59-
// Provides the AST of the hook's source file
60-
export function getASTFromSourceMap(
66+
// Map the generated source line and column position to the original source and line.
67+
export function mapCompiledLineNumberToOriginalLineNumber(
6168
consumer: SourceConsumer,
6269
lineNumber: number,
6370
columnNumber: number,
64-
): SourceFileASTWithHookDetails | null {
65-
// A check added to prevent parsing large files
66-
const FAIL_SAFE_CHECK = 100000;
67-
68-
// Map the generated source line and column position to the original source and line.
69-
const {line, source} = consumer.originalPositionFor({
71+
): number | null {
72+
const {line} = consumer.originalPositionFor({
7073
line: lineNumber,
7174
column: columnNumber,
7275
});
7376

74-
if (line == null) {
75-
console.error(
76-
`Could not find source location (line: ${lineNumber}, column: ${columnNumber})`,
77-
);
78-
return null;
79-
} else if (line > FAIL_SAFE_CHECK) {
80-
console.error(`Source File: ${source} is too big`);
81-
return null;
82-
}
83-
84-
const sourceFileContent = consumer.sourceContentFor(source, true);
85-
const sourceFileAST = parse(sourceFileContent, {
86-
sourceType: 'unambiguous',
87-
plugins: ['jsx', 'typescript'],
88-
});
89-
return {line, source, sourceFileAST};
77+
return line;
9078
}
9179

9280
// Returns all AST Nodes associated with 'potentialReactHookASTNode'

packages/react-devtools-extensions/src/parseHookNames.js

Lines changed: 85 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import {enableHookNameParsing} from 'react-devtools-feature-flags';
1414
import {SourceMapConsumer} from 'source-map';
1515
import {
1616
checkNodeLocation,
17-
getASTFromSourceMap,
1817
getFilteredHookASTNodes,
1918
getHookName,
2019
getPotentialHookDeclarationsFromAST,
2120
isConfirmedHookDeclaration,
2221
isNonDeclarativePrimitiveHook,
22+
mapCompiledLineNumberToOriginalLineNumber,
2323
} from './astUtils';
24+
import {sourceMapsAreAppliedToErrors} from './ErrorTester';
2425

2526
import type {
2627
HooksNode,
@@ -29,18 +30,35 @@ import type {
2930
} from 'react-debug-tools/src/ReactDebugHooks';
3031
import type {HookNames} from 'react-devtools-shared/src/hookNamesCache';
3132
import type {Thenable} from 'shared/ReactTypes';
32-
import type {SourceConsumer} from './astUtils';
33+
import type {SourceConsumer, SourceMap} from './astUtils';
3334

3435
const SOURCE_MAP_REGEX = / ?sourceMappingURL=([^\s'"]+)/gm;
3536
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
3637
const MAX_SOURCE_LENGTH = 100_000_000;
3738

3839
type HookSourceData = {|
40+
// Generated by react-debug-tools.
3941
hookSource: HookSource,
42+
43+
// AST for original source code; typically comes from a consumed source map.
44+
originalSourceAST: mixed,
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+
// Compiled code (React components or custom hooks) containing primitive hook calls.
51+
runtimeSourceCode: string | null,
52+
53+
// APIs from source-map for parsing source maps (if detected).
4054
sourceConsumer: SourceConsumer | null,
41-
sourceContents: string | null,
55+
56+
// External URL of source map.
57+
// Sources without source maps (or with inline source maps) won't have this.
4258
sourceMapURL: string | null,
43-
sourceMapContents: string | null,
59+
60+
// Parsed source map object.
61+
sourceMapContents: SourceMap | null,
4462
|};
4563

4664
export default async function parseHookNames(
@@ -72,8 +90,10 @@ export default async function parseHookNames(
7290
if (!fileNameToHookSourceData.has(fileName)) {
7391
fileNameToHookSourceData.set(fileName, {
7492
hookSource,
93+
originalSourceAST: null,
94+
originalSourceCode: null,
95+
runtimeSourceCode: null,
7596
sourceConsumer: null,
76-
sourceContents: null,
7797
sourceMapURL: null,
7898
sourceMapContents: null,
7999
});
@@ -85,7 +105,7 @@ export default async function parseHookNames(
85105

86106
return loadSourceFiles(fileNameToHookSourceData)
87107
.then(() => extractAndLoadSourceMaps(fileNameToHookSourceData))
88-
.then(() => parseSourceMaps(fileNameToHookSourceData))
108+
.then(() => parseSourceAST(fileNameToHookSourceData))
89109
.then(() => findHookNames(hooksList, fileNameToHookSourceData));
90110
}
91111

@@ -108,11 +128,10 @@ function extractAndLoadSourceMaps(
108128
): Promise<*> {
109129
const promises = [];
110130
fileNameToHookSourceData.forEach(hookSourceData => {
111-
const sourceMappingURLs = ((hookSourceData.sourceContents: any): string).match(
112-
SOURCE_MAP_REGEX,
113-
);
131+
const runtimeSourceCode = ((hookSourceData.runtimeSourceCode: any): string);
132+
const sourceMappingURLs = runtimeSourceCode.match(SOURCE_MAP_REGEX);
114133
if (sourceMappingURLs == null) {
115-
// Maybe file has not been transformed; let's try to parse it as-is.
134+
// Maybe file has not been transformed; we'll try to parse it as-is in parseSourceAST().
116135
} else {
117136
for (let i = 0; i < sourceMappingURLs.length; i++) {
118137
const sourceMappingURL = sourceMappingURLs[i];
@@ -217,63 +236,50 @@ function findHookNames(
217236
return null; // Should not be reachable.
218237
}
219238

220-
let hooksFromAST;
221-
let potentialReactHookASTNode;
222-
let sourceCode;
223-
224239
const sourceConsumer = hookSourceData.sourceConsumer;
225-
if (sourceConsumer) {
226-
const astData = getASTFromSourceMap(
240+
241+
let originalSourceLineNumber;
242+
if (sourceMapsAreAppliedToErrors || !sourceConsumer) {
243+
// Either the current environment automatically applies source maps to errors,
244+
// or the current code had no source map to begin with.
245+
// Either way, we don't need to convert the Error stack frame locations.
246+
originalSourceLineNumber = lineNumber;
247+
} else {
248+
originalSourceLineNumber = mapCompiledLineNumberToOriginalLineNumber(
227249
sourceConsumer,
228250
lineNumber,
229251
columnNumber,
230252
);
253+
}
231254

232-
if (astData === null) {
233-
return null;
234-
}
235-
236-
const {sourceFileAST, line, source} = astData;
237-
238-
sourceCode = source;
239-
hooksFromAST = getPotentialHookDeclarationsFromAST(sourceFileAST);
240-
241-
// Iterate through potential hooks and try to find the current hook.
242-
// potentialReactHookASTNode will contain declarations of the form const X = useState(0);
243-
// where X could be an identifier or an array pattern (destructuring syntax)
244-
potentialReactHookASTNode = hooksFromAST.find(node => {
245-
const nodeLocationCheck = checkNodeLocation(node, line);
246-
const hookDeclaractionCheck = isConfirmedHookDeclaration(node);
247-
return nodeLocationCheck && hookDeclaractionCheck;
248-
});
249-
} else {
250-
sourceCode = hookSourceData.sourceContents;
251-
252-
// There's no source map to parse here so we can use the source contents directly.
253-
const ast = parse(sourceCode, {
254-
sourceType: 'unambiguous',
255-
plugins: ['jsx', 'typescript'],
256-
});
257-
hooksFromAST = getPotentialHookDeclarationsFromAST(ast);
258-
const line = ((hookSource.lineNumber: any): number);
259-
potentialReactHookASTNode = hooksFromAST.find(node => {
260-
const nodeLocationCheck = checkNodeLocation(node, line);
261-
const hookDeclaractionCheck = isConfirmedHookDeclaration(node);
262-
return nodeLocationCheck && hookDeclaractionCheck;
263-
});
255+
if (originalSourceLineNumber === null) {
256+
return null;
264257
}
265258

266-
if (!sourceCode || !potentialReactHookASTNode) {
259+
const hooksFromAST = getPotentialHookDeclarationsFromAST(
260+
hookSourceData.originalSourceAST,
261+
);
262+
const potentialReactHookASTNode = hooksFromAST.find(node => {
263+
const nodeLocationCheck = checkNodeLocation(
264+
node,
265+
((originalSourceLineNumber: any): number),
266+
);
267+
const hookDeclaractionCheck = isConfirmedHookDeclaration(node);
268+
return nodeLocationCheck && hookDeclaractionCheck;
269+
});
270+
271+
if (!potentialReactHookASTNode) {
267272
return null;
268273
}
269274

270275
// nodesAssociatedWithReactHookASTNode could directly be used to obtain the hook variable name
271276
// depending on the type of potentialReactHookASTNode
272277
try {
278+
const originalSourceCode = ((hookSourceData.originalSourceCode: any): string);
273279
const nodesAssociatedWithReactHookASTNode = getFilteredHookASTNodes(
274280
potentialReactHookASTNode,
275281
hooksFromAST,
276-
sourceCode,
282+
originalSourceCode,
277283
);
278284

279285
return getHookName(
@@ -305,19 +311,19 @@ function loadSourceFiles(
305311
const promises = [];
306312
fileNameToHookSourceData.forEach((hookSourceData, fileName) => {
307313
promises.push(
308-
fetchFile(fileName).then(sourceContents => {
309-
if (sourceContents.length > MAX_SOURCE_LENGTH) {
314+
fetchFile(fileName).then(runtimeSourceCode => {
315+
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
310316
throw Error('Source code too large to parse');
311317
}
312318

313-
hookSourceData.sourceContents = sourceContents;
319+
hookSourceData.runtimeSourceCode = runtimeSourceCode;
314320
}),
315321
);
316322
});
317323
return Promise.all(promises);
318324
}
319325

320-
async function parseSourceMaps(
326+
async function parseSourceAST(
321327
fileNameToHookSourceData: Map<string, HookSourceData>,
322328
): Promise<*> {
323329
// SourceMapConsumer.initialize() does nothing when running in Node (aka Jest)
@@ -332,19 +338,41 @@ async function parseSourceMaps(
332338

333339
const promises = [];
334340
fileNameToHookSourceData.forEach(hookSourceData => {
335-
if (hookSourceData.sourceMapContents !== null) {
341+
const {runtimeSourceCode, sourceMapContents} = hookSourceData;
342+
if (sourceMapContents !== null) {
336343
// Parse and extract the AST from the source map.
337344
promises.push(
338345
SourceMapConsumer.with(
339-
hookSourceData.sourceMapContents,
346+
sourceMapContents,
340347
null,
341348
(sourceConsumer: SourceConsumer) => {
342349
hookSourceData.sourceConsumer = sourceConsumer;
350+
351+
// Now that the source map has been loaded,
352+
// extract the original source for later.
353+
const source = sourceMapContents.sources[0];
354+
const originalSourceCode = sourceConsumer.sourceContentFor(
355+
source,
356+
true,
357+
);
358+
359+
// Save the original source and parsed AST for later.
360+
// TODO (named hooks) Cache this across components, per source/file name.
361+
hookSourceData.originalSourceCode = originalSourceCode;
362+
hookSourceData.originalSourceAST = parse(originalSourceCode, {
363+
sourceType: 'unambiguous',
364+
plugins: ['jsx', 'typescript'],
365+
});
343366
},
344367
),
345368
);
346369
} else {
347-
// There's no source map to parse here so we can skip this step.
370+
// There's no source map to parse here so we can just parse the original source itself.
371+
hookSourceData.originalSourceCode = runtimeSourceCode;
372+
hookSourceData.originalSourceAST = parse(runtimeSourceCode, {
373+
sourceType: 'unambiguous',
374+
plugins: ['jsx', 'typescript'],
375+
});
348376
}
349377
});
350378
return Promise.all(promises);

0 commit comments

Comments
 (0)