errors: more robust error handling

This commit is contained in:
Hunter Miller 2021-09-23 14:35:55 -05:00
parent a9917cf2ec
commit b0ccb67adc
11 changed files with 252 additions and 1921 deletions

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,7 @@
"query-string": "^7.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-router-dom": "^5.2.0",
"slugify": "^1.6.0",
"zustand": "^3.5.7"

View File

@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory, useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { Grid } from './pages/Grid';
import useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes';
@ -11,6 +12,8 @@ import { useMedia } from './logic/useMedia';
import { useHarkStore } from './state/hark';
import { useTheme } from './state/settings';
import { useLocalState } from './state/local';
import { ErrorAlert } from './components/ErrorAlert';
import { useErrorHandler } from './logic/useErrorHandler';
const getNoteRedirect = (path: string) => {
if (path.startsWith('/desk/')) {
@ -23,6 +26,7 @@ const getNoteRedirect = (path: string) => {
const AppRoutes = () => {
const { push } = useHistory();
const { search } = useLocation();
const handleError = useErrorHandler();
useEffect(() => {
const query = new URLSearchParams(search);
@ -31,6 +35,7 @@ const AppRoutes = () => {
push(redir);
}
}, [search]);
const theme = useTheme();
const isDarkMode = useMedia('(prefers-color-scheme: dark)');
@ -46,23 +51,28 @@ const AppRoutes = () => {
useEffect(() => {}, []);
useEffect(() => {
useEffect(
handleError(() => {
window.name = 'grid';
const { fetchDefaultAlly, fetchAllies, fetchCharges } = useDocketState.getState();
fetchDefaultAlly();
fetchCharges();
fetchAllies();
const { fetchVats, fetchLag } = useKilnState.getState();
fetchVats();
fetchLag();
useContactState.getState().initialize(api);
useHarkStore.getState().initialize(api);
Mousetrap.bind(['command+/', 'ctrl+/'], () => {
push('/leap/search');
});
}, []);
}),
[]
);
return (
<Switch>
@ -76,8 +86,10 @@ export function App() {
const base = import.meta.env.MODE === 'mock' ? undefined : '/apps/grid';
return (
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => window.location.reload()}>
<BrowserRouter basename={base}>
<AppRoutes />
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import cn from 'classnames';
import { Dialog, DialogClose, DialogContent } from './Dialog';
import { Button } from './Button';
interface ErrorAlertProps {
error: Error;
resetErrorBoundary: () => void;
className?: string;
}
const SubmitIssue = ({ error }: { error: Error }) => {
const title = error.message;
const body = `\`\`\`%0A${error.stack?.replaceAll('\n', '%0A')}%0A\`\`\``;
return (
<Button
as="a"
variant="caution"
href={`https://github.com/urbit/landscape/issues/new?assignees=&labels=bug&title=${title}&body=${body}`}
target="_blank"
rel="noreferrer"
>
Submit Issue
</Button>
);
};
export const ErrorAlert = ({ error, resetErrorBoundary, className }: ErrorAlertProps) => {
return (
<Dialog defaultOpen modal onOpenChange={() => resetErrorBoundary()}>
<DialogContent
showClose={false}
className={cn('pr-8 space-y-6', className)}
containerClass="w-full max-w-3xl"
>
<h2 className="h4">
<span className="mr-3 text-orange-500">Encountered error:</span>
<span className="font-mono">{error.message}</span>
</h2>
{error.stack && (
<div className="w-full p-2 bg-gray-50 overflow-x-auto rounded">
<pre>{error.stack}</pre>
</div>
)}
<div className="flex space-x-6">
<DialogClose as={Button} variant="secondary">
Try Again
</DialogClose>
<DialogClose as={SubmitIssue} error={error} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,17 @@
import { useErrorHandler as useBoundaryHandler } from 'react-error-boundary';
export function useErrorHandler() {
const handle = useBoundaryHandler();
function handleError(cb: (...args: any[]) => any) {
return (...args: any[]) => {
try {
cb(...args);
} catch (error) {
handle(error);
}
};
}
return handleError;
}

View File

@ -13,6 +13,7 @@ import React, {
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
import { Cross } from '../components/icons/Cross';
import { useDebounce } from '../logic/useDebounce';
import { useErrorHandler } from '../logic/useErrorHandler';
import { MenuState, useLeapStore } from './Nav';
function normalizePathEnding(path: string) {
@ -58,6 +59,7 @@ export const Leap = React.forwardRef(
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current);
const { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
const handleError = useErrorHandler();
useEffect(() => {
if (selection && rawInput === '') {
@ -123,7 +125,7 @@ export const Leap = React.forwardRef(
const handleSearch = useCallback(debouncedSearch, [match]);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
handleError((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
@ -148,12 +150,12 @@ export const Leap = React.forwardRef(
}
handleSearch(value);
},
}),
[matches]
);
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
handleError((e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const value = inputRef.current?.value.trim();
@ -170,12 +172,12 @@ export const Leap = React.forwardRef(
push(currentMatch.url);
useLeapStore.setState({ rawInput: '' });
},
}),
[match, selectedMatch]
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
handleError((e: KeyboardEvent<HTMLDivElement>) => {
const deletion = e.key === 'Backspace' || e.key === 'Delete';
const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp';
@ -207,7 +209,7 @@ export const Leap = React.forwardRef(
selectedMatch: newMatch
});
}
},
}),
[selection, rawInput, match, matches, selectedMatch]
);

View File

@ -2,9 +2,11 @@ import { DialogContent } from '@radix-ui/react-dialog';
import * as Portal from '@radix-ui/react-portal';
import classNames from 'classnames';
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Route, Switch, useHistory, useRouteMatch } from 'react-router-dom';
import create from 'zustand';
import { Dialog } from '../components/Dialog';
import { ErrorAlert } from '../components/ErrorAlert';
import { Help } from './Help';
import { Leap } from './Leap';
import { Notifications } from './Notifications';
@ -106,7 +108,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
}, []);
return (
<>
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => push('/')}>
{/* Using portal so that we can retain the same nav items both in the dialog and in the base header */}
<Portal.Root
containerRef={dialogContentOpen ? dialogNavRef : navRef}
@ -172,6 +174,6 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
</div>
</DialogContent>
</Dialog>
</>
</ErrorBoundary>
);
};

View File

@ -1,10 +1,12 @@
import React, { useEffect } from 'react';
import { Link, NavLink, Route, Switch } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { Link, NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { Button } from '../components/Button';
import { ErrorAlert } from '../components/ErrorAlert';
import { useHarkStore } from '../state/hark';
import { Inbox } from './notifications/Inbox';
export const Notifications = () => {
export const Notifications = ({ history }: RouteComponentProps) => {
const markAllAsRead = () => {
const { archiveAll } = useHarkStore.getState();
archiveAll();
@ -26,6 +28,10 @@ export const Notifications = () => {
// const select = useLeapStore((s) => s.select);
return (
<ErrorBoundary
FallbackComponent={ErrorAlert}
onReset={() => history.push('/leap/notifications')}
>
<div className="grid grid-rows-[auto,1fr] h-full p-4 md:p-8 overflow-hidden">
<header className="space-x-2 mb-8">
<NavLink
@ -64,5 +70,6 @@ export const Notifications = () => {
</Route>
</Switch>
</div>
</ErrorBoundary>
);
};

View File

@ -1,16 +1,19 @@
import React from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { TreatyInfo } from './search/TreatyInfo';
import { Apps } from './search/Apps';
import { Home } from './search/Home';
import { Providers } from './search/Providers';
import { ErrorAlert } from '../components/ErrorAlert';
type SearchProps = RouteComponentProps<{
query?: string;
}>;
export const Search = ({ match }: SearchProps) => {
export const Search = ({ match, history }: SearchProps) => {
return (
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => history.push('/leap/search')}>
<Switch>
<Route
path={[`${match.path}/direct/apps/:host/:desk`, `${match.path}/:ship/apps/:host/:desk`]}
@ -20,5 +23,6 @@ export const Search = ({ match }: SearchProps) => {
<Route path={`${match.path}/:ship`} component={Providers} />
<Route path={`${match.path}`} component={Home} />
</Switch>
</ErrorBoundary>
);
};

View File

@ -1,5 +1,6 @@
import React, { PropsWithChildren, useCallback } from 'react';
import { Link, Route, RouteComponentProps, Switch, useRouteMatch } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import classNames from 'classnames';
import { NotificationPrefs } from './preferences/NotificationPrefs';
import { SystemUpdatePrefs } from './preferences/SystemUpdatePrefs';
@ -9,6 +10,7 @@ import { InterfacePrefs } from './preferences/InterfacePrefs';
import { useCharges } from '../state/docket';
import { AppPrefs } from './preferences/AppPrefs';
import { DocketImage } from '../components/DocketImage';
import { ErrorAlert } from '../components/ErrorAlert';
interface SystemPreferencesSectionProps {
url: string;
@ -36,7 +38,7 @@ function SystemPreferencesSection({
}
export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }>) => {
const { match } = props;
const { match, history } = props;
const subMatch = useRouteMatch<{ submenu: string; desk?: string }>(
`${match.url}/:submenu/:desk?`
);
@ -60,6 +62,10 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
const subUrl = useCallback((submenu: string) => `${match.url}/${submenu}`, [match]);
return (
<ErrorBoundary
FallbackComponent={ErrorAlert}
onReset={() => history.push('/leap/system-preferences')}
>
<div className="flex h-full overflow-y-auto">
<aside className="flex-none self-start min-w-60 py-8 font-semibold border-r-2 border-gray-50">
<nav className="px-6">
@ -109,5 +115,6 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
</Switch>
</section>
</div>
</ErrorBoundary>
);
};

View File

@ -1,6 +1,8 @@
import { map, omit } from 'lodash';
import React, { FunctionComponent } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Route, RouteComponentProps } from 'react-router-dom';
import { ErrorAlert } from '../components/ErrorAlert';
import { MenuState, Nav } from '../nav/Nav';
import { useCharges } from '../state/docket';
import { RemoveApp } from '../tiles/RemoveApp';
@ -12,7 +14,7 @@ type GridProps = RouteComponentProps<{
menu?: MenuState;
}>;
export const Grid: FunctionComponent<GridProps> = ({ match }) => {
export const Grid: FunctionComponent<GridProps> = ({ match, history }) => {
const charges = useCharges();
const chargesLoaded = Object.keys(charges).length > 0;
@ -32,6 +34,7 @@ export const Grid: FunctionComponent<GridProps> = ({ match }) => {
))}
</div>
)}
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => history.push('/')}>
<Route exact path="/app/:desk">
<TileInfo />
</Route>
@ -41,6 +44,7 @@ export const Grid: FunctionComponent<GridProps> = ({ match }) => {
<Route exact path="/app/:desk/remove">
<RemoveApp />
</Route>
</ErrorBoundary>
</main>
</div>
);