1/**
2 * Copyright (c) 650 Industries.
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
9import type { LogBoxLogData } from './LogBoxLog';
10import parseErrorStack from '../modules/parseErrorStack';
11import stringifySafe from '../modules/stringifySafe';
12type ExceptionData = any;
13
14const BABEL_TRANSFORM_ERROR_FORMAT =
15  /^(?:TransformError )?(?:SyntaxError: |ReferenceError: )(.*): (.*) \((\d+):(\d+)\)\n\n([\s\S]+)/;
16const BABEL_CODE_FRAME_ERROR_FORMAT =
17  /^(?:TransformError )?(?:.*):? (?:.*?)(\/.*): ([\s\S]+?)\n([ >]{2}[\d\s]+ \|[\s\S]+|\u{001b}[\s\S]+)/u;
18const METRO_ERROR_FORMAT =
19  /^(?:InternalError Metro has encountered an error:) (.*): (.*) \((\d+):(\d+)\)\n\n([\s\S]+)/u;
20
21export type ExtendedExceptionData = ExceptionData & {
22  isComponentError: boolean;
23  [key: string]: any;
24};
25export type Category = string;
26export type CodeFrame = {
27  content: string;
28  location?: {
29    row: number;
30    column: number;
31    [key: string]: any;
32  } | null;
33  fileName: string;
34
35  // TODO: When React switched to using call stack frames,
36  // we gained the ability to use the collapse flag, but
37  // it is not integrated into the LogBox UI.
38  collapse?: boolean;
39};
40
41export type Message = {
42  content: string;
43  substitutions: {
44    length: number;
45    offset: number;
46  }[];
47};
48
49export type ComponentStack = CodeFrame[];
50
51const SUBSTITUTION = '\ufeff%s';
52
53export function parseInterpolation(args: readonly any[]): {
54  category: Category;
55  message: Message;
56} {
57  const categoryParts: string[] = [];
58  const contentParts: string[] = [];
59  const substitutionOffsets: { length: number; offset: number }[] = [];
60
61  const remaining = [...args];
62  if (typeof remaining[0] === 'string') {
63    const formatString = String(remaining.shift());
64    const formatStringParts = formatString.split('%s');
65    const substitutionCount = formatStringParts.length - 1;
66    const substitutions = remaining.splice(0, substitutionCount);
67
68    let categoryString = '';
69    let contentString = '';
70
71    let substitutionIndex = 0;
72    for (const formatStringPart of formatStringParts) {
73      categoryString += formatStringPart;
74      contentString += formatStringPart;
75
76      if (substitutionIndex < substitutionCount) {
77        if (substitutionIndex < substitutions.length) {
78          // Don't stringify a string type.
79          // It adds quotation mark wrappers around the string,
80          // which causes the LogBox to look odd.
81          const substitution =
82            typeof substitutions[substitutionIndex] === 'string'
83              ? substitutions[substitutionIndex]
84              : stringifySafe(substitutions[substitutionIndex]);
85          substitutionOffsets.push({
86            length: substitution.length,
87            offset: contentString.length,
88          });
89
90          categoryString += SUBSTITUTION;
91          contentString += substitution;
92        } else {
93          substitutionOffsets.push({
94            length: 2,
95            offset: contentString.length,
96          });
97
98          categoryString += '%s';
99          contentString += '%s';
100        }
101
102        substitutionIndex++;
103      }
104    }
105
106    categoryParts.push(categoryString);
107    contentParts.push(contentString);
108  }
109
110  const remainingArgs = remaining.map((arg) => {
111    // Don't stringify a string type.
112    // It adds quotation mark wrappers around the string,
113    // which causes the LogBox to look odd.
114    return typeof arg === 'string' ? arg : stringifySafe(arg);
115  });
116  categoryParts.push(...remainingArgs);
117  contentParts.push(...remainingArgs);
118
119  return {
120    category: categoryParts.join(' '),
121    message: {
122      content: contentParts.join(' '),
123      substitutions: substitutionOffsets,
124    },
125  };
126}
127
128function isComponentStack(consoleArgument: string) {
129  const isOldComponentStackFormat = / {4}in/.test(consoleArgument);
130  const isNewComponentStackFormat = / {4}at/.test(consoleArgument);
131  const isNewJSCComponentStackFormat = /@.*\n/.test(consoleArgument);
132
133  return isOldComponentStackFormat || isNewComponentStackFormat || isNewJSCComponentStackFormat;
134}
135
136export function parseComponentStack(message: string): ComponentStack {
137  // In newer versions of React, the component stack is formatted as a call stack frame.
138  // First try to parse the component stack as a call stack frame, and if that doesn't
139  // work then we'll fallback to the old custom component stack format parsing.
140  const stack = parseErrorStack(message);
141  if (stack && stack.length > 0) {
142    return stack.map((frame) => ({
143      content: frame.methodName,
144      collapse: frame.collapse || false,
145      fileName: frame.file == null ? 'unknown' : frame.file,
146      location: {
147        column: frame.column == null ? -1 : frame.column,
148        row: frame.lineNumber == null ? -1 : frame.lineNumber,
149      },
150    }));
151  }
152
153  return message
154    .split(/\n {4}in /g)
155    .map((s) => {
156      if (!s) {
157        return null;
158      }
159      const match = s.match(/(.*) \(at (.*\.js):([\d]+)\)/);
160      if (!match) {
161        return null;
162      }
163
164      const [content, fileName, row] = match.slice(1);
165      return {
166        content,
167        fileName,
168        location: { column: -1, row: parseInt(row, 10) },
169      };
170    })
171    .filter(Boolean) as ComponentStack;
172}
173
174export function parseLogBoxException(error: ExtendedExceptionData): LogBoxLogData {
175  const message = error.originalMessage != null ? error.originalMessage : 'Unknown';
176
177  const metroInternalError = message.match(METRO_ERROR_FORMAT);
178  if (metroInternalError) {
179    const [content, fileName, row, column, codeFrame] = metroInternalError.slice(1);
180
181    return {
182      level: 'fatal',
183      type: 'Metro Error',
184      stack: [],
185      isComponentError: false,
186      componentStack: [],
187      codeFrame: {
188        fileName,
189        location: {
190          row: parseInt(row, 10),
191          column: parseInt(column, 10),
192        },
193        content: codeFrame,
194      },
195      message: {
196        content,
197        substitutions: [],
198      },
199      category: `${fileName}-${row}-${column}`,
200    };
201  }
202
203  const babelTransformError = message.match(BABEL_TRANSFORM_ERROR_FORMAT);
204  if (babelTransformError) {
205    // Transform errors are thrown from inside the Babel transformer.
206    const [fileName, content, row, column, codeFrame] = babelTransformError.slice(1);
207
208    return {
209      level: 'syntax',
210      stack: [],
211      isComponentError: false,
212      componentStack: [],
213      codeFrame: {
214        fileName,
215        location: {
216          row: parseInt(row, 10),
217          column: parseInt(column, 10),
218        },
219        content: codeFrame,
220      },
221      message: {
222        content,
223        substitutions: [],
224      },
225      category: `${fileName}-${row}-${column}`,
226    };
227  }
228
229  const babelCodeFrameError = message.match(BABEL_CODE_FRAME_ERROR_FORMAT);
230
231  if (babelCodeFrameError) {
232    // Codeframe errors are thrown from any use of buildCodeFrameError.
233    const [fileName, content, codeFrame] = babelCodeFrameError.slice(1);
234    return {
235      level: 'syntax',
236      stack: [],
237      isComponentError: false,
238      componentStack: [],
239      codeFrame: {
240        fileName,
241        location: null, // We are not given the location.
242        content: codeFrame,
243      },
244      message: {
245        content,
246        substitutions: [],
247      },
248      category: `${fileName}-${1}-${1}`,
249    };
250  }
251
252  if (message.match(/^TransformError /)) {
253    return {
254      level: 'syntax',
255      stack: error.stack,
256      isComponentError: error.isComponentError,
257      componentStack: [],
258      message: {
259        content: message,
260        substitutions: [],
261      },
262      category: message,
263    };
264  }
265
266  const componentStack = error.componentStack;
267  if (error.isFatal || error.isComponentError) {
268    return {
269      level: 'fatal',
270      stack: error.stack,
271      isComponentError: error.isComponentError,
272      componentStack: componentStack != null ? parseComponentStack(componentStack) : [],
273      ...parseInterpolation([message]),
274    };
275  }
276
277  if (componentStack != null) {
278    // It is possible that console errors have a componentStack.
279    return {
280      level: 'error',
281      stack: error.stack,
282      isComponentError: error.isComponentError,
283      componentStack: parseComponentStack(componentStack),
284      ...parseInterpolation([message]),
285    };
286  }
287
288  // Most `console.error` calls won't have a componentStack. We parse them like
289  // regular logs which have the component stack burried in the message.
290  return {
291    level: 'error',
292    stack: error.stack,
293    isComponentError: error.isComponentError,
294    ...parseLogBoxLog([message]),
295  };
296}
297
298export function parseLogBoxLog(args: readonly any[]): {
299  componentStack: ComponentStack;
300  category: Category;
301  message: Message;
302} {
303  const message = args[0];
304  let argsWithoutComponentStack: any[] = [];
305  let componentStack: ComponentStack = [];
306
307  // Extract component stack from warnings like "Some warning%s".
308  if (typeof message === 'string' && message.slice(-2) === '%s' && args.length > 0) {
309    const lastArg = args[args.length - 1];
310    if (typeof lastArg === 'string' && isComponentStack(lastArg)) {
311      argsWithoutComponentStack = args.slice(0, -1);
312      argsWithoutComponentStack[0] = message.slice(0, -2);
313      componentStack = parseComponentStack(lastArg);
314    }
315  }
316
317  if (componentStack.length === 0) {
318    // Try finding the component stack elsewhere.
319    for (const arg of args) {
320      if (typeof arg === 'string' && isComponentStack(arg)) {
321        // Strip out any messages before the component stack.
322        let messageEndIndex = arg.search(/\n {4}(in|at) /);
323        if (messageEndIndex < 0) {
324          // Handle JSC component stacks.
325          messageEndIndex = arg.search(/\n/);
326        }
327        if (messageEndIndex > 0) {
328          argsWithoutComponentStack.push(arg.slice(0, messageEndIndex));
329        }
330
331        componentStack = parseComponentStack(arg);
332      } else {
333        argsWithoutComponentStack.push(arg);
334      }
335    }
336  }
337
338  return {
339    ...parseInterpolation(argsWithoutComponentStack),
340    componentStack,
341  };
342}
343