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

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,16 +23,17 @@ 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 (
<Dialog <ErrorBoundary name={`Dialog ${id}`}>
open <Dialog
key={id} open
onClose={() => { onClose={() => {
onClose?.(); onClose?.();
hideDialog(id); hideDialog(id);
}} }}
{...props} {...props}
> >
{children} {children}
</Dialog> </Dialog>
</ErrorBoundary>
); );
} }

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>;
}

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,27 +93,29 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
/> />
</div> </div>
</HStack> </HStack>
<AutoScroller <ErrorBoundary name="GRPC Events">
data={events} <AutoScroller
header={ data={events}
activeConnection.error && ( header={
<Banner color="danger" className="m-3"> activeConnection.error && (
{activeConnection.error} <Banner color="danger" className="m-3">
</Banner> {activeConnection.error}
) </Banner>
} )
render={(event) => ( }
<EventRow render={(event) => (
key={event.id} <EventRow
event={event} key={event.id}
isActive={event.id === activeEventId} event={event}
onClick={() => { isActive={event.id === activeEventId}
if (event.id === activeEventId) setActiveEventId(null); onClick={() => {
else setActiveEventId(event.id); if (event.id === activeEventId) setActiveEventId(null);
}} else setActiveEventId(event.id);
/> }}
)} />
/> )}
/>
</ErrorBoundary>
</div> </div>
) )
} }

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,35 +156,37 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
tabListClassName="mt-1.5" tabListClassName="mt-1.5"
> >
<TabContent value={TAB_BODY}> <TabContent value={TAB_BODY}>
<ConfirmLargeResponse response={activeResponse}> <ErrorBoundary name="Http Response Viewer">
{activeResponse.state === 'initialized' ? ( <ConfirmLargeResponse response={activeResponse}>
<EmptyStateText> {activeResponse.state === 'initialized' ? (
<LoadingIcon size="xl" className="text-text-subtlest" /> <EmptyStateText>
</EmptyStateText> <LoadingIcon size="xl" className="text-text-subtlest" />
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? ( </EmptyStateText>
<EmptyStateText>Empty </EmptyStateText> ) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( <EmptyStateText>Empty </EmptyStateText>
<EventStreamViewer response={activeResponse} /> ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
) : mimeType?.match(/^image\/svg/) ? ( <EventStreamViewer response={activeResponse} />
<SvgViewer response={activeResponse} /> ) : mimeType?.match(/^image\/svg/) ? (
) : mimeType?.match(/^image/i) ? ( <SvgViewer response={activeResponse} />
<EnsureCompleteResponse response={activeResponse} render={ImageViewer} /> ) : mimeType?.match(/^image/i) ? (
) : mimeType?.match(/^audio/i) ? ( <EnsureCompleteResponse response={activeResponse} render={ImageViewer} />
<EnsureCompleteResponse response={activeResponse} render={AudioViewer} /> ) : mimeType?.match(/^audio/i) ? (
) : mimeType?.match(/^video/i) ? ( <EnsureCompleteResponse response={activeResponse} render={AudioViewer} />
<EnsureCompleteResponse response={activeResponse} render={VideoViewer} /> ) : mimeType?.match(/^video/i) ? (
) : mimeType?.match(/pdf/i) ? ( <EnsureCompleteResponse response={activeResponse} render={VideoViewer} />
<EnsureCompleteResponse response={activeResponse} render={PdfViewer} /> ) : mimeType?.match(/pdf/i) ? (
) : mimeType?.match(/csv|tab-separated/i) ? ( <EnsureCompleteResponse response={activeResponse} render={PdfViewer} />
<CsvViewer className="pb-2" response={activeResponse} /> ) : mimeType?.match(/csv|tab-separated/i) ? (
) : ( <CsvViewer className="pb-2" response={activeResponse} />
<HTMLOrTextViewer ) : (
textViewerClassName="-mr-2 bg-surface" // Pull to the right <HTMLOrTextViewer
response={activeResponse} textViewerClassName="-mr-2 bg-surface" // Pull to the right
pretty={viewMode === 'pretty'} response={activeResponse}
/> pretty={viewMode === 'pretty'}
)} />
</ConfirmLargeResponse> )}
</ConfirmLargeResponse>
</ErrorBoundary>
</TabContent> </TabContent>
<TabContent value={TAB_HEADERS}> <TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} /> <ResponseHeaders response={activeResponse} />

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}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ErrorBoundary name="Markdown">
{children} <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
</ReactMarkdown> {children}
</ReactMarkdown>
</ErrorBoundary>
</Prose> </Prose>
); );
} }

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

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,16 +23,17 @@ export const Toasts = () => {
{toasts.map((toast: ToastInstance) => { {toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast; const { message, uniqueKey, ...props } = toast;
return ( return (
<Toast <ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
key={uniqueKey} <Toast
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
// it gets called from external close calls as well // it gets called from external close calls as well
onClose={() => hideToast(toast)} onClose={() => hideToast(toast)}
> >
{message} {message}
</Toast> </Toast>
</ErrorBoundary>
); );
})} })}
</AnimatePresence> </AnimatePresence>

View File

@@ -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,27 +94,29 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
/> />
</HStack> </HStack>
</HStack> </HStack>
<AutoScroller <ErrorBoundary name="Websocket Events">
data={events} <AutoScroller
header={ data={events}
activeConnection.error && ( header={
<Banner color="danger" className="m-3"> activeConnection.error && (
{activeConnection.error} <Banner color="danger" className="m-3">
</Banner> {activeConnection.error}
) </Banner>
} )
render={(event) => ( }
<EventRow render={(event) => (
key={event.id} <EventRow
event={event} key={event.id}
isActive={event.id === activeEventId} event={event}
onClick={() => { isActive={event.id === activeEventId}
if (event.id === activeEventId) setActiveEventId(null); onClick={() => {
else setActiveEventId(event.id); if (event.id === activeEventId) setActiveEventId(null);
}} else setActiveEventId(event.id);
/> }}
)} />
/> )}
/>
</ErrorBoundary>
</div> </div>
) )
} }

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>
<Sidebar /> <ErrorBoundary name="Sidebar (Floating)">
<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')}>
<Sidebar className="border-r border-border-subtle" /> <ErrorBoundary name="Sidebar">
<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>
<WorkspaceBody /> <ErrorBoundary name="Workspace Body">
<WorkspaceBody />
</ErrorBoundary>
</div> </div>
); );
} }

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,17 +203,19 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
return ( return (
<> <>
{child} {child}
<Menu <ErrorBoundary name={`Dropdown Menu`}>
ref={menuRef} <Menu
showTriangle ref={menuRef}
triggerRef={buttonRef} showTriangle
fullWidth={fullWidth} triggerRef={buttonRef}
defaultSelectedIndex={defaultSelectedIndex} fullWidth={fullWidth}
items={items} defaultSelectedIndex={defaultSelectedIndex}
triggerShape={triggerRect ?? null} items={items}
onClose={() => setIsOpen(false)} triggerShape={triggerRect ?? null}
isOpen={isOpen} onClose={() => setIsOpen(false)}
/> isOpen={isOpen}
/>
</ErrorBoundary>
</> </>
); );
}); });

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,12 +154,14 @@ export const TabContent = memo(function TabContent({
className, className,
}: TabContentProps) { }: TabContentProps) {
return ( return (
<div <ErrorBoundary name={`Tab ${value}`}>
tabIndex={-1} <div
data-tab={value} tabIndex={-1}
className={classNames(className, 'tab-content', 'hidden w-full h-full')} data-tab={value}
> className={classNames(className, 'tab-content', 'hidden w-full h-full')}
{children} >
</div> {children}
</div>
</ErrorBoundary>
); );
}); });