// Currently only one error is saved at a time for display import Constants from 'expo-constants'; import * as React from 'react'; import { View, ScrollView, Text, StyleSheet, Platform } from 'react-native'; import * as Files from './Files'; import * as Logger from './Logger'; import * as Messaging from './Messaging'; import * as Modules from './Modules'; import ExceptionManager from './NativeModules/ExceptionManager'; type Props = { children: React.ReactNode; }; type State = { error: Error | null; attemptRender: boolean; content: React.ReactNode; }; // Save an error for display let initialError: Error | null = null; let setError = (e: Error | null) => { initialError = e; }; let getError = () => initialError; // Replace React Native's top-level exception handler with our own. Also its // `console.error(...)`-to-exception forwarder skips this override so disable that too. // // See https://github.com/expo/react-native/blob/exp-latest/Libraries/Core/ExceptionsManager.js ExceptionManager.handleException = (e: Error, _isFatal: boolean) => { report(e); }; // @ts-ignore console.reportErrorsAsExceptions = false; // Report an error export const report = (e: Error) => { Logger.error(e); setError(e); // Ensure the error is a real error if (typeof e !== 'object') { e = new Error(String(e)); } // Try to resolve the location of the error const name = e.name; const stack = e.stack; const message = e.message; // @ts-ignore: fileName is not a default field of Error let fileName: string = e.fileName || ''; // @ts-ignore: line fields are optional let lineNumber: number | undefined = e.lineNumber ?? e.startLine ?? e.line; // @ts-ignore: column fields are optional let columnNumber: number | undefined = e.columnNumber ?? e.startColumn ?? e.column; const lines = message.split('\n'); for (let i = 0; i < lines.length; i++) { let match = lines[i].match(/module:\/+(.*):(.*)\s\((\d+):(\d+)(\n|\))/); if (match) { fileName = Modules.sanitizeModule(match[1]); lineNumber = Number(match[3]); columnNumber = Number(match[4]); break; } match = lines[i].match(/module:\/+(.*)/); if (match) { fileName = Modules.sanitizeModule(match[1]); if (Files.get(fileName)) { break; } } } // Send error Messaging.publish({ type: 'ERROR', error: JSON.stringify({ name, message, fileName, lineNumber, columnNumber, stack, }), }); }; export const status = () => (getError() ? 'FAILURE' : 'SUCCESS'); /** * Generate a human-readable description of the error stack trace. * This does a few things: * - Try to map transpiled code back to original source code with the known sourcemaps * - Filters references to the Snack client bundle code, since that's irrelevant for the user's code * - Filters Hermes internal bytecode references * - Clean up file names to remove the `module://` prefix, and `.js.js` suffix * - Clean up faulty column numbers and correct the line numbers, caused by `Files.tsx`'s `applyPatch` newlines */ export function prettyStack(error: Error) { // Unmap transpiled code from known sourcemaps const sourceUnmappedStack = error.stack?.replace( /(module:\/\/[^:]+):(\d+):(\d+)(\n|\))/g, (match, sourceURL, line, column) => { const u = Modules.unmap({ sourceURL, line: parseInt(line, 10), column: parseInt(column, 10), }); return u ? // Avoid adding the `column`, `source-map@0.6.1` does not properly resolve the column. It uses the generated column number. u.path + (u.line !== null && u.column !== null ? `:${u.line})\n` : '\n') : match.replace(/module:\/+/, '').replace(/.([a-z]+).js/g, '.$1'); }, ); if (!sourceUnmappedStack) { return 'No stacktrace available'; } return sourceUnmappedStack .split(/\r?\n/) .filter((line) => !line.match(/https?:\/\/.+/g)) // Filter bundle-related stacks .filter((line) => !line.match(/\(native\)/g)) // Filter (native) stacks .filter((line) => !line.match(/InternalBytecode/g)) // Filter Hermes bytecode stacks .filter((line) => !line.match(/\(address at/g)) // Filter Android specific address stacks .filter(Boolean) // Filter empty lines .join('\n'); } // Acts as a boundary for upward error propagation in the React render tree. Displays errors with a // friendly dialog. export class ErrorBoundary extends React.Component { constructor(props: Props) { super(props); this.state = { error: initialError, content: React.Children.only(props.children), attemptRender: true, }; } static getDerivedStateFromProps(props: Props, state: State) { // ErrorBoundary expects only a single child so it can // efficiently determine whether the content has changed. const content = React.Children.only(props.children); if (state.content !== content) { return { content, attemptRender: true, // Reset error when new content has been received error: content ? null : state.error, }; } return null; } componentDidMount() { setError = (error) => this.setState(() => ({ error })); getError = () => this.state.error; } componentWillUnmount() { setError = (_error: Error | null) => {}; getError = () => null; } componentDidCatch(error: Error) { Logger.error(error); // This is called by React when an error occurs during rendering, so save the error and stop // rendering for now this.setState({ error, attemptRender: false }); } render() { const { error, attemptRender } = this.state; const { children } = this.props; return ( {attemptRender && this._renderChildren(children)} {error && this._renderOverlay(error)} ); } _renderChildren(children: React.ReactNode) { return {children}; } _renderOverlay(error: Error) { return ( Did you know: You can turn off automatic updates under Devices in the footer? {this._renderErrorMessage(error)} {this._renderStackTrace(error)} ); } _renderErrorMessage(error: Error) { const lines = error.message.split(/\r\n?|\n/); const children = []; let text = ''; let isCode = false; lines.forEach((line) => { if (!line.trim()) return; line = line.replace(/module:\/+/g, '').replace(/\.js\.js/g, '.js'); if (line.match(isCode ? /\|/ : /^\s*\d+\s*\|/)) { if (text && !isCode) { children.push( {text} , ); text = ''; } isCode = true; } else if (isCode) { children.push( {text} , ); isCode = false; text = ''; } if (text) { text = `${text}\n${line}`; } else { text = line; } }); if (text) { children.push( {text} , ); } return {children}; } _renderStackTrace(error: Error) { return {prettyStack(error)}; } } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: 'transparent', }, children: { flex: 1, }, overlay: { position: 'absolute', top: 28 + Constants.statusBarHeight, bottom: 28, left: 28, right: 28, backgroundColor: '#f44336', borderColor: '#922820', borderRadius: 16, borderTopWidth: 0, borderLeftWidth: 0.4, borderRightWidth: 0.4, borderBottomWidth: 1.2, shadowColor: 'black', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.7, shadowRadius: 42, padding: 8, }, header: { paddingBottom: 8, paddingHorizontal: 8, borderBottomWidth: 2, borderBottomColor: 'white', flexDirection: 'row', }, title: { color: 'white', }, titleBold: { fontWeight: 'bold', }, content: { backgroundColor: 'transparent' }, contentContainer: { padding: 8, }, message: { marginBottom: 16, }, plain: { color: 'white', fontSize: 16, fontWeight: 'bold', marginBottom: 8, }, code: { fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace', color: 'white', fontSize: 15, fontWeight: 'bold', marginBottom: 8, }, stack: { color: 'white', fontSize: 14, }, });