main
js 366 lines 11.7 KB
Raw
1 /**
2 /**
3 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 *
5 * This source code is licensed under the MIT license found in the
6 * LICENSE file in the root directory of this source tree.
7 *
8 * @flow
9 */
10
11 import type {ReactStackTrace, ReactFunctionLocation} from 'shared/ReactTypes';
12
13 function parseStackTraceFromChromeStack(
14 stack: string,
15 skipFrames: number,
16 ): ReactStackTrace {
17 if (stack.startsWith('Error: react-stack-top-frame\n')) {
18 // V8's default formatting prefixes with the error message which we
19 // don't want/need.
20 stack = stack.slice(29);
21 }
22 let idx = stack.indexOf('react_stack_bottom_frame');
23 if (idx === -1) {
24 idx = stack.indexOf('react-stack-bottom-frame');
25 }
26 if (idx !== -1) {
27 idx = stack.lastIndexOf('\n', idx);
28 }
29 if (idx !== -1) {
30 // Cut off everything after the bottom frame since it'll be internals.
31 stack = stack.slice(0, idx);
32 }
33 const frames = stack.split('\n');
34 const parsedFrames: ReactStackTrace = [];
35 // We skip top frames here since they may or may not be parseable but we
36 // want to skip the same number of frames regardless. I.e. we can't do it
37 // in the caller.
38 for (let i = skipFrames; i < frames.length; i++) {
39 const parsed = chromeFrameRegExp.exec(frames[i]);
40 if (!parsed) {
41 continue;
42 }
43 let name = parsed[1] || '';
44 let isAsync = parsed[8] === 'async ';
45 if (name === '<anonymous>') {
46 name = '';
47 } else if (name.startsWith('async ')) {
48 name = name.slice(5);
49 isAsync = true;
50 }
51 let filename = parsed[2] || parsed[5] || '';
52 if (filename === '<anonymous>') {
53 filename = '';
54 }
55 const line = +(parsed[3] || parsed[6] || 0);
56 const col = +(parsed[4] || parsed[7] || 0);
57 parsedFrames.push([name, filename, line, col, 0, 0, isAsync]);
58 }
59 return parsedFrames;
60 }
61
62 const firefoxFrameRegExp = /^((?:.*".+")?[^@]*)@(.+):(\d+):(\d+)$/;
63 function parseStackTraceFromFirefoxStack(
64 stack: string,
65 skipFrames: number,
66 ): ReactStackTrace {
67 let idx = stack.indexOf('react_stack_bottom_frame');
68 if (idx === -1) {
69 idx = stack.indexOf('react-stack-bottom-frame');
70 }
71 if (idx !== -1) {
72 idx = stack.lastIndexOf('\n', idx);
73 }
74 if (idx !== -1) {
75 // Cut off everything after the bottom frame since it'll be internals.
76 stack = stack.slice(0, idx);
77 }
78 const frames = stack.split('\n');
79 const parsedFrames: ReactStackTrace = [];
80 // We skip top frames here since they may or may not be parseable but we
81 // want to skip the same number of frames regardless. I.e. we can't do it
82 // in the caller.
83 for (let i = skipFrames; i < frames.length; i++) {
84 const parsed = firefoxFrameRegExp.exec(frames[i]);
85 if (!parsed) {
86 continue;
87 }
88 const name = parsed[1] || '';
89 const filename = parsed[2] || '';
90 const line = +parsed[3];
91 const col = +parsed[4];
92 parsedFrames.push([name, filename, line, col, 0, 0, false]);
93 }
94 return parsedFrames;
95 }
96
97 const CHROME_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
98 export function parseStackTraceFromString(
99 stack: string,
100 skipFrames: number,
101 ): ReactStackTrace {
102 if (stack.match(CHROME_STACK_REGEXP)) {
103 return parseStackTraceFromChromeStack(stack, skipFrames);
104 }
105 return parseStackTraceFromFirefoxStack(stack, skipFrames);
106 }
107
108 let framesToSkip: number = 0;
109 let collectedStackTrace: null | ReactStackTrace = null;
110
111 const identifierRegExp = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
112
113 function getMethodCallName(callSite: CallSite): string {
114 const typeName = callSite.getTypeName();
115 const methodName = callSite.getMethodName();
116 const functionName = callSite.getFunctionName();
117 let result = '';
118 if (functionName) {
119 if (
120 typeName &&
121 identifierRegExp.test(functionName) &&
122 functionName !== typeName
123 ) {
124 result += typeName + '.';
125 }
126 result += functionName;
127 if (
128 methodName &&
129 functionName !== methodName &&
130 !functionName.endsWith('.' + methodName) &&
131 !functionName.endsWith(' ' + methodName)
132 ) {
133 result += ' [as ' + methodName + ']';
134 }
135 } else {
136 if (typeName) {
137 result += typeName + '.';
138 }
139 if (methodName) {
140 result += methodName;
141 } else {
142 result += '<anonymous>';
143 }
144 }
145 return result;
146 }
147
148 function collectStackTrace(
149 error: Error,
150 structuredStackTrace: CallSite[],
151 ): string {
152 const result: ReactStackTrace = [];
153 // Collect structured stack traces from the callsites.
154 // We mirror how V8 serializes stack frames and how we later parse them.
155 for (let i = framesToSkip; i < structuredStackTrace.length; i++) {
156 const callSite = structuredStackTrace[i];
157 let name =
158 // $FlowFixMe[method-unbinding]
159 typeof callSite.getFunctionName === 'function'
160 ? callSite.getFunctionName() || '<anonymous>'
161 : '';
162 if (
163 name.includes('react_stack_bottom_frame') ||
164 name.includes('react-stack-bottom-frame')
165 ) {
166 // Skip everything after the bottom frame since it'll be internals.
167 break;
168 // $FlowFixMe[method-unbinding]
169 } else if (typeof callSite.isNative === 'function' && callSite.isNative()) {
170 const isAsync =
171 // $FlowFixMe[prop-missing]
172 // $FlowFixMe[incompatible-use]
173 typeof callSite.isAsync === 'function' && callSite.isAsync();
174 result.push([name, '', 0, 0, 0, 0, isAsync]);
175 } else {
176 // We encode complex function calls as if they're part of the function
177 // name since we cannot simulate the complex ones and they look the same
178 // as function names in UIs on the client as well as stacks.
179 if (
180 // $FlowFixMe[method-unbinding]
181 typeof callSite.isConstructor === 'function' &&
182 callSite.isConstructor()
183 ) {
184 name = 'new ' + name;
185 } else if (
186 // $FlowFixMe[method-unbinding]
187 typeof callSite.isToplevel === 'function' &&
188 !callSite.isToplevel()
189 ) {
190 name = getMethodCallName(callSite);
191 }
192 if (name === '<anonymous>') {
193 name = '';
194 }
195 let filename =
196 // $FlowFixMe[method-unbinding]
197 typeof callSite.getScriptNameOrSourceURL === 'function'
198 ? callSite.getScriptNameOrSourceURL() || '<anonymous>'
199 : '';
200 if (filename === '<anonymous>') {
201 filename = '';
202 // $FlowFixMe[method-unbinding]
203 if (typeof callSite.isEval === 'function' && callSite.isEval()) {
204 const origin =
205 // $FlowFixMe[method-unbinding]
206 typeof callSite.getEvalOrigin === 'function'
207 ? callSite.getEvalOrigin()
208 : null;
209 if (origin) {
210 filename = origin.toString() + ', <anonymous>';
211 }
212 }
213 }
214 const line =
215 // $FlowFixMe[method-unbinding]
216 (typeof callSite.getLineNumber === 'function' &&
217 callSite.getLineNumber()) ||
218 0;
219 const col =
220 // $FlowFixMe[method-unbinding]
221 (typeof callSite.getColumnNumber === 'function' &&
222 callSite.getColumnNumber()) ||
223 0;
224 const enclosingLine: number =
225 // $FlowFixMe[prop-missing]
226 typeof callSite.getEnclosingLineNumber === 'function'
227 ? (callSite as any).getEnclosingLineNumber() || 0
228 : 0;
229 const enclosingCol: number =
230 // $FlowFixMe[prop-missing]
231 typeof callSite.getEnclosingColumnNumber === 'function'
232 ? (callSite as any).getEnclosingColumnNumber() || 0
233 : 0;
234 const isAsync =
235 // $FlowFixMe[prop-missing]
236 // $FlowFixMe[incompatible-use]
237 typeof callSite.isAsync === 'function' && callSite.isAsync();
238 result.push([
239 name,
240 filename,
241 line,
242 col,
243 enclosingLine,
244 enclosingCol,
245 isAsync,
246 ]);
247 }
248 }
249 collectedStackTrace = result;
250
251 // At the same time we generate a string stack trace just in case someone
252 // else reads it. Ideally, we'd call the previous prepareStackTrace to
253 // ensure it's in the expected format but it's common for that to be
254 // source mapped and since we do a lot of eager parsing of errors, it
255 // would be slow in those environments. We could maybe just rely on those
256 // environments having to disable source mapping globally to speed things up.
257 // For now, we just generate a default V8 formatted stack trace without
258 // source mapping as a fallback.
259 const name = error.name || 'Error';
260 const message = error.message || '';
261 let stack = name + ': ' + message;
262 for (let i = 0; i < structuredStackTrace.length; i++) {
263 stack += '\n at ' + structuredStackTrace[i].toString();
264 }
265 return stack;
266 }
267
268 // This matches either of these V8 formats.
269 // at name (filename:0:0)
270 // at filename:0:0
271 // at async filename:0:0
272 // at Array.map (<anonymous>)
273 const chromeFrameRegExp =
274 /^ *at (?:(.+) \((?:(.+):(\d+):(\d+)|\<anonymous\>)\)|(?:async )?(.+):(\d+):(\d+)|\<anonymous\>)$/;
275
276 const stackTraceCache: WeakMap<Error, ReactStackTrace> = new WeakMap();
277
278 export function parseStackTrace(
279 error: Error,
280 skipFrames: number,
281 ): ReactStackTrace {
282 // We can only get structured data out of error objects once. So we cache the information
283 // so we can get it again each time. It also helps performance when the same error is
284 // referenced more than once.
285 const existing = stackTraceCache.get(error);
286 if (existing !== undefined) {
287 return existing;
288 }
289 // We override Error.prepareStackTrace with our own version that collects
290 // the structured data. We need more information than the raw stack gives us
291 // and we need to ensure that we don't get the source mapped version.
292 collectedStackTrace = null;
293 framesToSkip = skipFrames;
294 const previousPrepare = Error.prepareStackTrace;
295 Error.prepareStackTrace = collectStackTrace;
296 let stack;
297 try {
298 stack = String(error.stack);
299 } finally {
300 Error.prepareStackTrace = previousPrepare;
301 }
302
303 if (collectedStackTrace !== null) {
304 const result = collectedStackTrace;
305 collectedStackTrace = null;
306 stackTraceCache.set(error, result);
307 return result;
308 }
309
310 // If the stack has already been read, or this is not actually a V8 compatible
311 // engine then we might not get a normalized stack and it might still have been
312 // source mapped. Regardless we try our best to parse it.
313
314 const parsedFrames = parseStackTraceFromString(stack, skipFrames);
315 stackTraceCache.set(error, parsedFrames);
316 return parsedFrames;
317 }
318
319 export function extractLocationFromOwnerStack(
320 error: Error,
321 ): ReactFunctionLocation | null {
322 const stackTrace = parseStackTrace(error, 1);
323 const stack = error.stack;
324 if (
325 !stack.includes('react_stack_bottom_frame') &&
326 !stack.includes('react-stack-bottom-frame')
327 ) {
328 // This didn't have a bottom to it, we can't trust it.
329 return null;
330 }
331 // We start from the bottom since that will have the best location for the owner itself.
332 for (let i = stackTrace.length - 1; i >= 0; i--) {
333 const [functionName, fileName, line, col, encLine, encCol] = stackTrace[i];
334 // Take the first match with a colon in the file name.
335 if (fileName.indexOf(':') !== -1) {
336 return [
337 functionName,
338 fileName,
339 // Use enclosing line if available, since that points to the start of the function.
340 encLine || line,
341 encCol || col,
342 ];
343 }
344 }
345 return null;
346 }
347
348 export function extractLocationFromComponentStack(
349 stack: string,
350 ): ReactFunctionLocation | null {
351 const stackTrace = parseStackTraceFromString(stack, 0);
352 for (let i = 0; i < stackTrace.length; i++) {
353 const [functionName, fileName, line, col, encLine, encCol] = stackTrace[i];
354 // Take the first match with a colon in the file name.
355 if (fileName.indexOf(':') !== -1) {
356 return [
357 functionName,
358 fileName,
359 // Use enclosing line if available. (Never the case here because we parse from string.)
360 encLine || line,
361 encCol || col,
362 ];
363 }
364 }
365 return null;
366 }