diff --git a/package.json b/package.json index 484b82053..9d2198d71 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@types/signal-exit": "^3.0.0", "@types/sinon": "^7.5.2", "@types/slice-ansi": "^2.0.0", + "@types/stack-utils": "^1.0.1", "@types/wrap-ansi": "^3.0.0", "ansi-escapes": "^4.2.1", "auto-bind": "4.0.0", @@ -54,6 +55,7 @@ "cli-boxes": "^2.2.0", "cli-cursor": "^3.1.0", "cli-truncate": "^2.1.0", + "code-excerpt": "^3.0.0", "indent-string": "^4.0.0", "is-ci": "^2.0.0", "lodash.throttle": "^4.1.1", @@ -63,6 +65,7 @@ "scheduler": "^0.18.0", "signal-exit": "^3.0.2", "slice-ansi": "^3.0.0", + "stack-utils": "^2.0.2", "string-length": "^3.1.0", "type-fest": "^0.12.0", "widest-line": "^3.1.0", diff --git a/src/components/App.tsx b/src/components/App.tsx index 79b5c519c..b4585822a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7,6 +7,7 @@ import StdinContext from './StdinContext'; import StdoutContext from './StdoutContext'; import StderrContext from './StderrContext'; import FocusContext from './FocusContext'; +import ErrorOverview from './ErrorOverview'; const TAB = '\t'; const SHIFT_TAB = '\u001B[Z'; @@ -27,6 +28,7 @@ interface State { isFocusEnabled: boolean; activeFocusId?: string; focusables: Focusable[]; + error?: Error; } interface Focusable { @@ -43,13 +45,18 @@ export default class App extends PureComponent { state = { isFocusEnabled: true, activeFocusId: undefined, - focusables: [] + focusables: [], + error: undefined }; // Count how many components enabled raw mode to avoid disabling // raw mode until all components don't need it anymore rawModeEnabledCount = 0; + static getDerivedStateFromError(error: Error) { + return {error}; + } + // Determines if TTY is supported on the provided stdin isRawModeSupported(): boolean { return this.props.stdin.isTTY; @@ -94,7 +101,11 @@ export default class App extends PureComponent { focusPrevious: this.focusPrevious }} > - {this.props.children} + {this.state.error ? ( + + ) : ( + this.props.children + )} diff --git a/src/components/ErrorOverview.tsx b/src/components/ErrorOverview.tsx new file mode 100644 index 000000000..65f2abcf5 --- /dev/null +++ b/src/components/ErrorOverview.tsx @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import React from 'react'; +import type {FC} from 'react'; +import StackUtils from 'stack-utils'; +import codeExcerpt, {ExcerptLine} from 'code-excerpt'; +import Box from './Box'; +import Text from './Text'; + +const stackUtils = new StackUtils({ + cwd: process.cwd(), + internals: StackUtils.nodeInternals() +}); + +interface Props { + error: Error; +} + +const ErrorOverview: FC = ({error}) => { + const stack = error.stack!.split('\n').slice(1); + const origin = stackUtils.parseLine(stack[0]); + let excerpt: ExcerptLine[] | undefined; + let lineWidth = 0; + + if (origin?.file && origin?.line) { + const sourceCode = fs.readFileSync(origin.file, 'utf8'); + excerpt = codeExcerpt(sourceCode, origin.line); + + if (excerpt) { + for (const {line} of excerpt) { + lineWidth = Math.max(lineWidth, String(line).length); + } + } + } + + return ( + + + + {' '} + ERROR{' '} + + + {error.message} + + + {origin && ( + + + {origin.file}:{origin.line}:{origin.column} + + + )} + + {origin && excerpt && ( + + {excerpt.map(({line, value}) => ( + + + + {String(line).padStart(lineWidth, ' ')}: + + + + + {' ' + value} + + + ))} + + )} + + + {error + .stack!.split('\n') + .slice(1) + .map(line => { + const parsedLine = stackUtils.parseLine(line)!; + + return ( + + - + + {parsedLine.function} + + + {' '} + ({parsedLine.file}:{parsedLine.line}:{parsedLine.column}) + + + ); + })} + + + ); +}; + +export default ErrorOverview; diff --git a/src/ink.tsx b/src/ink.tsx index 696a24797..2412b5ae4 100644 --- a/src/ink.tsx +++ b/src/ink.tsx @@ -42,7 +42,7 @@ export default class Ink { // so that it's rerendered every time, not just new static parts, like in non-debug mode private fullStaticOutput: string; private readonly renderer: Renderer; - private readonly exitPromise: Promise; + private exitPromise?: Promise; private restoreConsole?: () => void; constructor(options: Options) { @@ -84,11 +84,6 @@ export default class Ink { this.container = reconciler.createContainer(this.rootNode, false, false); - this.exitPromise = new Promise((resolve, reject) => { - this.resolveExitPromise = resolve; - this.rejectExitPromise = reject; - }); - // Unmount when process exits this.unsubscribeExit = signalExit(this.unmount, {alwaysLast: false}); @@ -257,6 +252,13 @@ export default class Ink { } waitUntilExit(): Promise { + if (!this.exitPromise) { + this.exitPromise = new Promise((resolve, reject) => { + this.resolveExitPromise = resolve; + this.rejectExitPromise = reject; + }); + } + return this.exitPromise; } @@ -277,7 +279,11 @@ export default class Ink { } if (stream === 'stderr') { - this.writeToStderr(data); + const isReactMessage = data.startsWith('The above error occurred'); + + if (!isReactMessage) { + this.writeToStderr(data); + } } }); } diff --git a/test/errors.tsx b/test/errors.tsx new file mode 100644 index 000000000..9b7c15535 --- /dev/null +++ b/test/errors.tsx @@ -0,0 +1,50 @@ +/* eslint-disable unicorn/string-content */ +import React from 'react'; +import test from 'ava'; +import {spy} from 'sinon'; +import patchConsole from 'patch-console'; +import stripAnsi from 'strip-ansi'; +import {render} from '../src'; + +let restore; + +test.before(() => { + restore = patchConsole(); +}); + +test.after(() => { + restore(); +}); + +test('catch and display error', t => { + const stdout = { + columns: 100, + write: spy() + }; + + const Test = () => { + throw new Error('Oh no'); + }; + + render(, {stdout}); + + t.deepEqual( + stripAnsi(stdout.write.lastCall.args[0]).split('\n').slice(0, 14), + [ + '', + ' ERROR Oh no', + '', + ' test/errors.tsx:26:9', + '', + ' 23: };', + ' 24:', + ' 25: const Test = () => {', + " 26: throw new Error('Oh no');", + ' 27: };', + ' 28:', + ' 29: render(, {stdout});', + '', + ' - Test (test/errors.tsx:26:9)' + ] + ); +});