Skip to content

Commit

Permalink
Add global error boundary (#303)
Browse files Browse the repository at this point in the history
  • Loading branch information
Vadim Demedes authored Jun 18, 2020
1 parent 5990747 commit 2bcb4c0
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 9 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@
"@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",
"chalk": "^3.0.0",
"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",
Expand All @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,6 +28,7 @@ interface State {
isFocusEnabled: boolean;
activeFocusId?: string;
focusables: Focusable[];
error?: Error;
}

interface Focusable {
Expand All @@ -43,13 +45,18 @@ export default class App extends PureComponent<Props, State> {
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;
Expand Down Expand Up @@ -94,7 +101,11 @@ export default class App extends PureComponent<Props, State> {
focusPrevious: this.focusPrevious
}}
>
{this.props.children}
{this.state.error ? (
<ErrorOverview error={this.state.error! as Error} />
) : (
this.props.children
)}
</FocusContext.Provider>
</StderrContext.Provider>
</StdoutContext.Provider>
Expand Down
105 changes: 105 additions & 0 deletions src/components/ErrorOverview.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({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 (
<Box flexDirection="column" padding={1}>
<Box>
<Text backgroundColor="red" color="white">
{' '}
ERROR{' '}
</Text>

<Text> {error.message}</Text>
</Box>

{origin && (
<Box marginTop={1}>
<Text dimColor>
{origin.file}:{origin.line}:{origin.column}
</Text>
</Box>
)}

{origin && excerpt && (
<Box marginTop={1} flexDirection="column">
{excerpt.map(({line, value}) => (
<Box key={line}>
<Box width={lineWidth + 1}>
<Text
dimColor={line !== origin.line}
backgroundColor={line === origin.line ? 'red' : undefined}
color={line === origin.line ? 'white' : undefined}
>
{String(line).padStart(lineWidth, ' ')}:
</Text>
</Box>

<Text
key={line}
backgroundColor={line === origin.line ? 'red' : undefined}
color={line === origin.line ? 'white' : undefined}
>
{' ' + value}
</Text>
</Box>
))}
</Box>
)}

<Box marginTop={1} flexDirection="column">
{error
.stack!.split('\n')
.slice(1)
.map(line => {
const parsedLine = stackUtils.parseLine(line)!;

return (
<Box key={line}>
<Text dimColor>- </Text>
<Text dimColor bold>
{parsedLine.function}
</Text>
<Text dimColor color="gray">
{' '}
({parsedLine.file}:{parsedLine.line}:{parsedLine.column})
</Text>
</Box>
);
})}
</Box>
</Box>
);
};

export default ErrorOverview;
20 changes: 13 additions & 7 deletions src/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
private exitPromise?: Promise<void>;
private restoreConsole?: () => void;

constructor(options: Options) {
Expand Down Expand Up @@ -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});

Expand Down Expand Up @@ -257,6 +252,13 @@ export default class Ink {
}

waitUntilExit(): Promise<void> {
if (!this.exitPromise) {
this.exitPromise = new Promise((resolve, reject) => {
this.resolveExitPromise = resolve;
this.rejectExitPromise = reject;
});
}

return this.exitPromise;
}

Expand All @@ -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);
}
}
});
}
Expand Down
50 changes: 50 additions & 0 deletions test/errors.tsx
Original file line number Diff line number Diff line change
@@ -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(<Test />, {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(<Test />, {stdout});',
'',
' - Test (test/errors.tsx:26:9)'
]
);
});

0 comments on commit 2bcb4c0

Please sign in to comment.