Add error boundaries

This commit is contained in:
Gregory Schier
2025-05-12 15:53:21 -07:00
parent 035fe54df0
commit b3ede3d6d6
11 changed files with 215 additions and 118 deletions
+3 -1
View File
@@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai';
import React from 'react'; import React from 'react';
import { dialogsAtom, hideDialog } from '../lib/dialog'; import { dialogsAtom, hideDialog } from '../lib/dialog';
import { Dialog, type DialogProps } from './core/Dialog'; import { Dialog, type DialogProps } from './core/Dialog';
import { ErrorBoundary } from './ErrorBoundary';
export type DialogInstance = { export type DialogInstance = {
id: string; id: string;
@@ -22,9 +23,9 @@ export function Dialogs() {
function DialogInstance({ render, onClose, id, ...props }: DialogInstance) { function DialogInstance({ render, onClose, id, ...props }: DialogInstance) {
const children = render({ hide: () => hideDialog(id) }); const children = render({ hide: () => hideDialog(id) });
return ( return (
<ErrorBoundary name={`Dialog ${id}`}>
<Dialog <Dialog
open open
key={id}
onClose={() => { onClose={() => {
onClose?.(); onClose?.();
hideDialog(id); hideDialog(id);
@@ -33,5 +34,6 @@ function DialogInstance({ render, onClose, id, ...props }: DialogInstance) {
> >
{children} {children}
</Dialog> </Dialog>
</ErrorBoundary>
); );
} }
+68
View File
@@ -0,0 +1,68 @@
import type { ErrorInfo, ReactNode } from 'react';
import { Component, useEffect } from 'react';
import { showDialog } from '../lib/dialog';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import RouteError from './RouteError';
interface ErrorBoundaryProps {
name: string;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn('Error caught by ErrorBoundary:', error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: 'error-boundary',
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
}
export function ErrorBoundaryTestThrow() {
useEffect(() => {
throw new Error('test error');
});
return <div>Hello</div>;
}
+3
View File
@@ -25,6 +25,7 @@ import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout'; import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown'; import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
interface Props { interface Props {
@@ -92,6 +93,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
/> />
</div> </div>
</HStack> </HStack>
<ErrorBoundary name="GRPC Events">
<AutoScroller <AutoScroller
data={events} data={events}
header={ header={
@@ -113,6 +115,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
/> />
)} )}
/> />
</ErrorBoundary>
</div> </div>
) )
} }
+3
View File
@@ -30,6 +30,7 @@ import { ImageViewer } from './responseViewers/ImageViewer';
import { PdfViewer } from './responseViewers/PdfViewer'; import { PdfViewer } from './responseViewers/PdfViewer';
import { SvgViewer } from './responseViewers/SvgViewer'; import { SvgViewer } from './responseViewers/SvgViewer';
import { VideoViewer } from './responseViewers/VideoViewer'; import { VideoViewer } from './responseViewers/VideoViewer';
import { ErrorBoundary } from './ErrorBoundary';
interface Props { interface Props {
style?: CSSProperties; style?: CSSProperties;
@@ -155,6 +156,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
tabListClassName="mt-1.5" tabListClassName="mt-1.5"
> >
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<ConfirmLargeResponse response={activeResponse}> <ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? ( {activeResponse.state === 'initialized' ? (
<EmptyStateText> <EmptyStateText>
@@ -184,6 +186,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
/> />
)} )}
</ConfirmLargeResponse> </ConfirmLargeResponse>
</ErrorBoundary>
</TabContent> </TabContent>
<TabContent value={TAB_HEADERS}> <TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} /> <ResponseHeaders response={activeResponse} />
+4 -1
View File
@@ -1,13 +1,16 @@
import remarkGfm from 'remark-gfm';
import ReactMarkdown, { type Components } from 'react-markdown'; import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ErrorBoundary } from './ErrorBoundary';
import { Prose } from './Prose'; import { Prose } from './Prose';
export function Markdown({ children, className }: { children: string; className?: string }) { export function Markdown({ children, className }: { children: string; className?: string }) {
return ( return (
<Prose className={className}> <Prose className={className}>
<ErrorBoundary name="Markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children} {children}
</ReactMarkdown> </ReactMarkdown>
</ErrorBoundary>
</Prose> </Prose>
); );
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { FormattedError } from './core/FormattedError';
import { Heading } from './core/Heading'; import { Heading } from './core/Heading';
import { VStack } from './core/Stacks'; import { VStack } from './core/Stacks';
export default function RouteError({ error }: { error: unknown; reset: () => void }) { export default function RouteError({ error }: { error: unknown }) {
console.log('Error', error); console.log('Error', error);
const stringified = JSON.stringify(error); const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
+3 -1
View File
@@ -4,6 +4,7 @@ import React, { type ReactNode } from 'react';
import { hideToast, toastsAtom } from '../lib/toast'; import { hideToast, toastsAtom } from '../lib/toast';
import { Toast, type ToastProps } from './core/Toast'; import { Toast, type ToastProps } from './core/Toast';
import { Portal } from './Portal'; import { Portal } from './Portal';
import { ErrorBoundary } from './ErrorBoundary';
export type ToastInstance = { export type ToastInstance = {
id: string; id: string;
@@ -22,8 +23,8 @@ export const Toasts = () => {
{toasts.map((toast: ToastInstance) => { {toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast; const { message, uniqueKey, ...props } = toast;
return ( return (
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast <Toast
key={uniqueKey}
open open
{...props} {...props}
// We call onClose inside actions.hide instead of passing to toast so that // We call onClose inside actions.hide instead of passing to toast so that
@@ -32,6 +33,7 @@ export const Toasts = () => {
> >
{message} {message}
</Toast> </Toast>
</ErrorBoundary>
); );
})} })}
</AnimatePresence> </AnimatePresence>
@@ -27,6 +27,7 @@ import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { WebsocketStatusTag } from './core/WebsocketStatusTag'; import { WebsocketStatusTag } from './core/WebsocketStatusTag';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { RecentWebsocketConnectionsDropdown } from './RecentWebsocketConnectionsDropdown'; import { RecentWebsocketConnectionsDropdown } from './RecentWebsocketConnectionsDropdown';
interface Props { interface Props {
@@ -93,6 +94,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
/> />
</HStack> </HStack>
</HStack> </HStack>
<ErrorBoundary name="Websocket Events">
<AutoScroller <AutoScroller
data={events} data={events}
header={ header={
@@ -114,6 +116,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
/> />
)} )}
/> />
</ErrorBoundary>
</div> </div>
) )
} }
+7
View File
@@ -41,6 +41,7 @@ import { Sidebar } from './sidebar/Sidebar';
import { SidebarActions } from './sidebar/SidebarActions'; import { SidebarActions } from './sidebar/SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout'; import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader'; import { WorkspaceHeader } from './WorkspaceHeader';
import { ErrorBoundary } from './ErrorBoundary';
const side = { gridArea: 'side' }; const side = { gridArea: 'side' };
const head = { gridArea: 'head' }; const head = { gridArea: 'head' };
@@ -149,13 +150,17 @@ export function Workspace() {
<HeaderSize size="lg" className="border-transparent"> <HeaderSize size="lg" className="border-transparent">
<SidebarActions /> <SidebarActions />
</HeaderSize> </HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar /> <Sidebar />
</ErrorBoundary>
</m.div> </m.div>
</Overlay> </Overlay>
) : ( ) : (
<> <>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}> <div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" /> <Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div> </div>
<ResizeHandle <ResizeHandle
className="-translate-x-3" className="-translate-x-3"
@@ -175,7 +180,9 @@ export function Workspace() {
> >
<WorkspaceHeader className="pointer-events-none" /> <WorkspaceHeader className="pointer-events-none" />
</HeaderSize> </HeaderSize>
<ErrorBoundary name="Workspace Body">
<WorkspaceBody /> <WorkspaceBody />
</ErrorBoundary>
</div> </div>
); );
} }
+3
View File
@@ -37,6 +37,7 @@ import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon'; import { LoadingIcon } from './LoadingIcon';
import { Separator } from './Separator'; import { Separator } from './Separator';
import { HStack, VStack } from './Stacks'; import { HStack, VStack } from './Stacks';
import { ErrorBoundary } from '../ErrorBoundary';
export type DropdownItemSeparator = { export type DropdownItemSeparator = {
type: 'separator'; type: 'separator';
@@ -202,6 +203,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
return ( return (
<> <>
{child} {child}
<ErrorBoundary name={`Dropdown Menu`}>
<Menu <Menu
ref={menuRef} ref={menuRef}
showTriangle showTriangle
@@ -213,6 +215,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
isOpen={isOpen} isOpen={isOpen}
/> />
</ErrorBoundary>
</> </>
); );
}); });
+3
View File
@@ -5,6 +5,7 @@ import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown'; import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown'; import { RadioDropdown } from '../RadioDropdown';
import { HStack } from '../Stacks'; import { HStack } from '../Stacks';
import { ErrorBoundary } from '../../ErrorBoundary';
export type TabItem = export type TabItem =
| { | {
@@ -153,6 +154,7 @@ export const TabContent = memo(function TabContent({
className, className,
}: TabContentProps) { }: TabContentProps) {
return ( return (
<ErrorBoundary name={`Tab ${value}`}>
<div <div
tabIndex={-1} tabIndex={-1}
data-tab={value} data-tab={value}
@@ -160,5 +162,6 @@ export const TabContent = memo(function TabContent({
> >
{children} {children}
</div> </div>
</ErrorBoundary>
); );
}); });