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 * as React from 'react';
10
11import { LogBoxLog, StackType } from './LogBoxLog';
12import type { LogLevel } from './LogBoxLog';
13import { LogContext } from './LogContext';
14import { parseLogBoxException } from './parseLogBoxLog';
15import type { Message, Category, ComponentStack, ExtendedExceptionData } from './parseLogBoxLog';
16import NativeLogBox from '../modules/NativeLogBox';
17import parseErrorStack from '../modules/parseErrorStack';
18
19export type LogBoxLogs = Set<LogBoxLog>;
20
21export type LogData = {
22  level: LogLevel;
23  message: Message;
24  category: Category;
25  componentStack: ComponentStack;
26};
27
28type ExtendedError = any;
29
30export type Observer = (options: {
31  logs: LogBoxLogs;
32  isDisabled: boolean;
33  selectedLogIndex: number;
34}) => void;
35
36export type IgnorePattern = string | RegExp;
37
38export type Subscription = {
39  unsubscribe: () => void;
40};
41
42export type WarningInfo = {
43  finalFormat: string;
44  forceDialogImmediately: boolean;
45  suppressDialog_LEGACY: boolean;
46  suppressCompletely: boolean;
47  monitorEvent: string | null;
48  monitorListVersion: number;
49  monitorSampleRate: number;
50};
51
52export type WarningFilter = (format: string) => WarningInfo;
53
54type Props = object;
55
56type State = {
57  logs: LogBoxLogs;
58  isDisabled: boolean;
59  hasError: boolean;
60  selectedLogIndex: number;
61};
62
63const observers: Set<{ observer: Observer } & any> = new Set();
64const ignorePatterns: Set<IgnorePattern> = new Set();
65let logs: LogBoxLogs = new Set();
66let updateTimeout: null | ReturnType<typeof setImmediate> | ReturnType<typeof setTimeout> = null;
67let _isDisabled = false;
68let _selectedIndex = -1;
69
70const LOGBOX_ERROR_MESSAGE =
71  'An error was thrown when attempting to render log messages via LogBox.';
72
73function getNextState() {
74  return {
75    logs,
76    isDisabled: _isDisabled,
77    selectedLogIndex: _selectedIndex,
78  };
79}
80
81export function reportLogBoxError(error: ExtendedError, componentStack?: string): void {
82  const ExceptionsManager = require('../modules/ExceptionsManager').default;
83
84  if (componentStack != null) {
85    error.componentStack = componentStack;
86  }
87  ExceptionsManager.handleException(error);
88}
89
90export function reportUnexpectedLogBoxError(error: ExtendedError, componentStack?: string): void {
91  error.message = `${LOGBOX_ERROR_MESSAGE}\n\n${error.message}`;
92  return reportLogBoxError(error, componentStack);
93}
94
95export function isLogBoxErrorMessage(message: string): boolean {
96  return typeof message === 'string' && message.includes(LOGBOX_ERROR_MESSAGE);
97}
98
99export function isMessageIgnored(message: string): boolean {
100  for (const pattern of ignorePatterns) {
101    if (
102      (pattern instanceof RegExp && pattern.test(message)) ||
103      (typeof pattern === 'string' && message.includes(pattern))
104    ) {
105      return true;
106    }
107  }
108  return false;
109}
110
111function setImmediateShim(callback: () => void) {
112  if (!global.setImmediate) {
113    return setTimeout(callback, 0);
114  }
115  return global.setImmediate(callback);
116}
117
118function handleUpdate(): void {
119  if (updateTimeout == null) {
120    updateTimeout = setImmediateShim(() => {
121      updateTimeout = null;
122      const nextState = getNextState();
123      observers.forEach(({ observer }) => observer(nextState));
124    });
125  }
126}
127
128function appendNewLog(newLog: LogBoxLog): void {
129  // Don't want store these logs because they trigger a
130  // state update when we add them to the store.
131  if (isMessageIgnored(newLog.message.content)) {
132    return;
133  }
134
135  // If the next log has the same category as the previous one
136  // then roll it up into the last log in the list by incrementing
137  // the count (similar to how Chrome does it).
138  const lastLog = Array.from(logs).pop();
139  if (lastLog && lastLog.category === newLog.category) {
140    lastLog.incrementCount();
141    handleUpdate();
142    return;
143  }
144
145  if (newLog.level === 'fatal') {
146    // If possible, to avoid jank, we don't want to open the error before
147    // it's symbolicated. To do that, we optimistically wait for
148    // symbolication for up to a second before adding the log.
149    const OPTIMISTIC_WAIT_TIME = 1000;
150
151    let addPendingLog: null | (() => void) = () => {
152      logs.add(newLog);
153      if (_selectedIndex < 0) {
154        setSelectedLog(logs.size - 1);
155      } else {
156        handleUpdate();
157      }
158      addPendingLog = null;
159    };
160
161    const optimisticTimeout = setTimeout(() => {
162      if (addPendingLog) {
163        addPendingLog();
164      }
165    }, OPTIMISTIC_WAIT_TIME);
166
167    // TODO: HANDLE THIS
168    newLog.symbolicate('component');
169
170    newLog.symbolicate('stack', (status) => {
171      if (addPendingLog && status !== 'PENDING') {
172        addPendingLog();
173        clearTimeout(optimisticTimeout);
174      } else if (status !== 'PENDING') {
175        // The log has already been added but we need to trigger a render.
176        handleUpdate();
177      }
178    });
179  } else if (newLog.level === 'syntax') {
180    logs.add(newLog);
181    setSelectedLog(logs.size - 1);
182  } else {
183    logs.add(newLog);
184    handleUpdate();
185  }
186}
187
188export function addLog(log: LogData): void {
189  const errorForStackTrace = new Error();
190
191  // Parsing logs are expensive so we schedule this
192  // otherwise spammy logs would pause rendering.
193  setImmediate(() => {
194    try {
195      const stack = parseErrorStack(errorForStackTrace?.stack);
196
197      appendNewLog(
198        new LogBoxLog({
199          level: log.level,
200          message: log.message,
201          isComponentError: false,
202          stack,
203          category: log.category,
204          componentStack: log.componentStack,
205        })
206      );
207    } catch (error) {
208      reportUnexpectedLogBoxError(error);
209    }
210  });
211}
212
213export function addException(error: ExtendedExceptionData): void {
214  // Parsing logs are expensive so we schedule this
215  // otherwise spammy logs would pause rendering.
216  setImmediate(() => {
217    try {
218      appendNewLog(new LogBoxLog(parseLogBoxException(error)));
219    } catch (loggingError) {
220      reportUnexpectedLogBoxError(loggingError);
221    }
222  });
223}
224
225export function symbolicateLogNow(type: StackType, log: LogBoxLog) {
226  log.symbolicate(type, () => {
227    handleUpdate();
228  });
229}
230
231export function retrySymbolicateLogNow(type: StackType, log: LogBoxLog) {
232  log.retrySymbolicate(type, () => {
233    handleUpdate();
234  });
235}
236
237export function symbolicateLogLazy(type: StackType, log: LogBoxLog) {
238  log.symbolicate(type);
239}
240
241export function clear(): void {
242  if (logs.size > 0) {
243    logs = new Set();
244    setSelectedLog(-1);
245  }
246}
247
248export function setSelectedLog(proposedNewIndex: number): void {
249  const oldIndex = _selectedIndex;
250  let newIndex = proposedNewIndex;
251
252  const logArray = Array.from(logs);
253  let index = logArray.length - 1;
254  while (index >= 0) {
255    // The latest syntax error is selected and displayed before all other logs.
256    if (logArray[index].level === 'syntax') {
257      newIndex = index;
258      break;
259    }
260    index -= 1;
261  }
262  _selectedIndex = newIndex;
263  handleUpdate();
264  if (NativeLogBox) {
265    setTimeout(() => {
266      if (oldIndex < 0 && newIndex >= 0) {
267        NativeLogBox.show();
268      } else if (oldIndex >= 0 && newIndex < 0) {
269        NativeLogBox.hide();
270      }
271    }, 0);
272  }
273}
274
275export function clearWarnings(): void {
276  const newLogs = Array.from(logs).filter((log) => log.level !== 'warn');
277  if (newLogs.length !== logs.size) {
278    logs = new Set(newLogs);
279    setSelectedLog(-1);
280    handleUpdate();
281  }
282}
283
284export function clearErrors(): void {
285  const newLogs = Array.from(logs).filter((log) => log.level !== 'error' && log.level !== 'fatal');
286  if (newLogs.length !== logs.size) {
287    logs = new Set(newLogs);
288    setSelectedLog(-1);
289  }
290}
291
292export function dismiss(log: LogBoxLog): void {
293  if (logs.has(log)) {
294    logs.delete(log);
295    handleUpdate();
296  }
297}
298
299export function getIgnorePatterns(): IgnorePattern[] {
300  return Array.from(ignorePatterns);
301}
302
303export function addIgnorePatterns(patterns: IgnorePattern[]): void {
304  const existingSize = ignorePatterns.size;
305  // The same pattern may be added multiple times, but adding a new pattern
306  // can be expensive so let's find only the ones that are new.
307  patterns.forEach((pattern: IgnorePattern) => {
308    if (pattern instanceof RegExp) {
309      for (const existingPattern of ignorePatterns) {
310        if (
311          existingPattern instanceof RegExp &&
312          existingPattern.toString() === pattern.toString()
313        ) {
314          return;
315        }
316      }
317      ignorePatterns.add(pattern);
318    }
319    ignorePatterns.add(pattern);
320  });
321  if (ignorePatterns.size === existingSize) {
322    return;
323  }
324  // We need to recheck all of the existing logs.
325  // This allows adding an ignore pattern anywhere in the codebase.
326  // Without this, if you ignore a pattern after the a log is created,
327  // then we would keep showing the log.
328  logs = new Set(Array.from(logs).filter((log) => !isMessageIgnored(log.message.content)));
329  handleUpdate();
330}
331
332export function setDisabled(value: boolean): void {
333  if (value === _isDisabled) {
334    return;
335  }
336  _isDisabled = value;
337  handleUpdate();
338}
339
340export function isDisabled(): boolean {
341  return _isDisabled;
342}
343
344export function observe(observer: Observer): Subscription {
345  const subscription = { observer };
346  observers.add(subscription);
347
348  observer(getNextState());
349
350  return {
351    unsubscribe(): void {
352      observers.delete(subscription);
353    },
354  };
355}
356
357export function withSubscription(WrappedComponent: React.FC<object>): React.Component<object> {
358  class LogBoxStateSubscription extends React.Component<React.PropsWithChildren<Props>, State> {
359    static getDerivedStateFromError() {
360      return { hasError: true };
361    }
362
363    componentDidCatch(err: Error, errorInfo: { componentStack: string } & any) {
364      /* $FlowFixMe[class-object-subtyping] added when improving typing for
365       * this parameters */
366      reportLogBoxError(err, errorInfo.componentStack);
367    }
368
369    _subscription?: Subscription;
370
371    state = {
372      logs: new Set<LogBoxLog>(),
373      isDisabled: false,
374      hasError: false,
375      selectedLogIndex: -1,
376    };
377
378    render() {
379      if (this.state.hasError) {
380        // This happens when the component failed to render, in which case we delegate to the native redbox.
381        // We can't show any fallback UI here, because the error may be with <View> or <Text>.
382        return null;
383      }
384
385      return (
386        <LogContext.Provider
387          value={{
388            selectedLogIndex: this.state.selectedLogIndex,
389            isDisabled: this.state.isDisabled,
390            logs: Array.from(this.state.logs),
391          }}>
392          {this.props.children}
393          <WrappedComponent />
394        </LogContext.Provider>
395      );
396    }
397
398    componentDidMount(): void {
399      this._subscription = observe((data) => {
400        this.setState(data);
401      });
402    }
403
404    componentWillUnmount(): void {
405      if (this._subscription != null) {
406        this._subscription.unsubscribe();
407      }
408    }
409
410    _handleDismiss = (): void => {
411      // Here we handle the cases when the log is dismissed and it
412      // was either the last log, or when the current index
413      // is now outside the bounds of the log array.
414      const { selectedLogIndex, logs: stateLogs } = this.state;
415      const logsArray = Array.from(stateLogs);
416      if (selectedLogIndex != null) {
417        if (logsArray.length - 1 <= 0) {
418          setSelectedLog(-1);
419        } else if (selectedLogIndex >= logsArray.length - 1) {
420          setSelectedLog(selectedLogIndex - 1);
421        }
422
423        dismiss(logsArray[selectedLogIndex]);
424      }
425    };
426
427    _handleMinimize = (): void => {
428      setSelectedLog(-1);
429    };
430
431    _handleSetSelectedLog = (index: number): void => {
432      setSelectedLog(index);
433    };
434  }
435
436  // @ts-expect-error
437  return LogBoxStateSubscription;
438}
439