diff --git a/src-web/components/Dialogs.tsx b/src-web/components/Dialogs.tsx index 2b96f9e3..b96e0f83 100644 --- a/src-web/components/Dialogs.tsx +++ b/src-web/components/Dialogs.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai'; import React from 'react'; import { dialogsAtom, hideDialog } from '../lib/dialog'; import { Dialog, type DialogProps } from './core/Dialog'; +import { ErrorBoundary } from './ErrorBoundary'; export type DialogInstance = { id: string; @@ -22,16 +23,17 @@ export function Dialogs() { function DialogInstance({ render, onClose, id, ...props }: DialogInstance) { const children = render({ hide: () => hideDialog(id) }); return ( - { - onClose?.(); - hideDialog(id); - }} - {...props} - > - {children} - + + { + onClose?.(); + hideDialog(id); + }} + {...props} + > + {children} + + ); } diff --git a/src-web/components/ErrorBoundary.tsx b/src-web/components/ErrorBoundary.tsx new file mode 100644 index 00000000..5ab7fc11 --- /dev/null +++ b/src-web/components/ErrorBoundary.tsx @@ -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 { + 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 ( + + + Error rendering {this.props.name} component + + { + showDialog({ + id: 'error-boundary', + render: () => , + }); + }} + > + Show + + + ); + } + + return this.props.children; + } +} + +export function ErrorBoundaryTestThrow() { + useEffect(() => { + throw new Error('test error'); + }); + + return Hello; +} diff --git a/src-web/components/GrpcResponsePane.tsx b/src-web/components/GrpcResponsePane.tsx index 157048fc..ca0f9dd7 100644 --- a/src-web/components/GrpcResponsePane.tsx +++ b/src-web/components/GrpcResponsePane.tsx @@ -25,6 +25,7 @@ import { Separator } from './core/Separator'; import { SplitLayout } from './core/SplitLayout'; import { HStack, VStack } from './core/Stacks'; import { EmptyStateText } from './EmptyStateText'; +import { ErrorBoundary } from './ErrorBoundary'; import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown'; interface Props { @@ -92,27 +93,29 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { /> - - {activeConnection.error} - - ) - } - render={(event) => ( - { - if (event.id === activeEventId) setActiveEventId(null); - else setActiveEventId(event.id); - }} - /> - )} - /> + + + {activeConnection.error} + + ) + } + render={(event) => ( + { + if (event.id === activeEventId) setActiveEventId(null); + else setActiveEventId(event.id); + }} + /> + )} + /> + ) } diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index cf27cf6e..79ed88b0 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -30,6 +30,7 @@ import { ImageViewer } from './responseViewers/ImageViewer'; import { PdfViewer } from './responseViewers/PdfViewer'; import { SvgViewer } from './responseViewers/SvgViewer'; import { VideoViewer } from './responseViewers/VideoViewer'; +import { ErrorBoundary } from './ErrorBoundary'; interface Props { style?: CSSProperties; @@ -155,35 +156,37 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { tabListClassName="mt-1.5" > - - {activeResponse.state === 'initialized' ? ( - - - - ) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? ( - Empty - ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( - - ) : mimeType?.match(/^image\/svg/) ? ( - - ) : mimeType?.match(/^image/i) ? ( - - ) : mimeType?.match(/^audio/i) ? ( - - ) : mimeType?.match(/^video/i) ? ( - - ) : mimeType?.match(/pdf/i) ? ( - - ) : mimeType?.match(/csv|tab-separated/i) ? ( - - ) : ( - - )} - + + + {activeResponse.state === 'initialized' ? ( + + + + ) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? ( + Empty + ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( + + ) : mimeType?.match(/^image\/svg/) ? ( + + ) : mimeType?.match(/^image/i) ? ( + + ) : mimeType?.match(/^audio/i) ? ( + + ) : mimeType?.match(/^video/i) ? ( + + ) : mimeType?.match(/pdf/i) ? ( + + ) : mimeType?.match(/csv|tab-separated/i) ? ( + + ) : ( + + )} + + diff --git a/src-web/components/Markdown.tsx b/src-web/components/Markdown.tsx index b07b5baf..52d2bd00 100644 --- a/src-web/components/Markdown.tsx +++ b/src-web/components/Markdown.tsx @@ -1,13 +1,16 @@ -import remarkGfm from 'remark-gfm'; import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { ErrorBoundary } from './ErrorBoundary'; import { Prose } from './Prose'; export function Markdown({ children, className }: { children: string; className?: string }) { return ( - - {children} - + + + {children} + + ); } diff --git a/src-web/components/RouteError.tsx b/src-web/components/RouteError.tsx index 7ad05aff..0e2f12b5 100644 --- a/src-web/components/RouteError.tsx +++ b/src-web/components/RouteError.tsx @@ -3,7 +3,7 @@ import { FormattedError } from './core/FormattedError'; import { Heading } from './core/Heading'; 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); const stringified = JSON.stringify(error); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src-web/components/Toasts.tsx b/src-web/components/Toasts.tsx index ce0f34a9..d4c9458f 100644 --- a/src-web/components/Toasts.tsx +++ b/src-web/components/Toasts.tsx @@ -4,6 +4,7 @@ import React, { type ReactNode } from 'react'; import { hideToast, toastsAtom } from '../lib/toast'; import { Toast, type ToastProps } from './core/Toast'; import { Portal } from './Portal'; +import { ErrorBoundary } from './ErrorBoundary'; export type ToastInstance = { id: string; @@ -22,16 +23,17 @@ export const Toasts = () => { {toasts.map((toast: ToastInstance) => { const { message, uniqueKey, ...props } = toast; return ( - hideToast(toast)} - > - {message} - + + hideToast(toast)} + > + {message} + + ); })} diff --git a/src-web/components/WebsocketResponsePane.tsx b/src-web/components/WebsocketResponsePane.tsx index 4464031d..db28897c 100644 --- a/src-web/components/WebsocketResponsePane.tsx +++ b/src-web/components/WebsocketResponsePane.tsx @@ -27,6 +27,7 @@ import { SplitLayout } from './core/SplitLayout'; import { HStack, VStack } from './core/Stacks'; import { WebsocketStatusTag } from './core/WebsocketStatusTag'; import { EmptyStateText } from './EmptyStateText'; +import { ErrorBoundary } from './ErrorBoundary'; import { RecentWebsocketConnectionsDropdown } from './RecentWebsocketConnectionsDropdown'; interface Props { @@ -93,27 +94,29 @@ export function WebsocketResponsePane({ activeRequest }: Props) { /> - - {activeConnection.error} - - ) - } - render={(event) => ( - { - if (event.id === activeEventId) setActiveEventId(null); - else setActiveEventId(event.id); - }} - /> - )} - /> + + + {activeConnection.error} + + ) + } + render={(event) => ( + { + if (event.id === activeEventId) setActiveEventId(null); + else setActiveEventId(event.id); + }} + /> + )} + /> + ) } diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index 744061c4..5d49623c 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -41,6 +41,7 @@ import { Sidebar } from './sidebar/Sidebar'; import { SidebarActions } from './sidebar/SidebarActions'; import { WebsocketRequestLayout } from './WebsocketRequestLayout'; import { WorkspaceHeader } from './WorkspaceHeader'; +import { ErrorBoundary } from './ErrorBoundary'; const side = { gridArea: 'side' }; const head = { gridArea: 'head' }; @@ -149,13 +150,17 @@ export function Workspace() { - + + + ) : ( <> - + + + - + + + ); } diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 5d82c7ae..30ad2748 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -37,6 +37,7 @@ import { Icon } from './Icon'; import { LoadingIcon } from './LoadingIcon'; import { Separator } from './Separator'; import { HStack, VStack } from './Stacks'; +import { ErrorBoundary } from '../ErrorBoundary'; export type DropdownItemSeparator = { type: 'separator'; @@ -202,17 +203,19 @@ export const Dropdown = forwardRef(function Dropdown return ( <> {child} - setIsOpen(false)} - isOpen={isOpen} - /> + + setIsOpen(false)} + isOpen={isOpen} + /> + > ); }); diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index 2f90c4fe..001ea59d 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -5,6 +5,7 @@ import { Icon } from '../Icon'; import type { RadioDropdownProps } from '../RadioDropdown'; import { RadioDropdown } from '../RadioDropdown'; import { HStack } from '../Stacks'; +import { ErrorBoundary } from '../../ErrorBoundary'; export type TabItem = | { @@ -153,12 +154,14 @@ export const TabContent = memo(function TabContent({ className, }: TabContentProps) { return ( - - {children} - + + + {children} + + ); });