From b600e186261650477786557928b7331a142a6ec9 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 19 Jun 2026 09:09:45 -0700 Subject: [PATCH] fix(ui): contain routed-page render errors with a boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A render-time throw on a routed page (e.g. the New Agent page at /agents/new?adapterType=claude_local) had no boundary, so it unmounted the whole SPA and left a blank page. Because the custom router updates location without remounting, the back button changed the URL but the tree stayed dead until a hard refresh. Wrap the routed in a RouteErrorBoundary that renders a recoverable error card, keeps the sidebar/nav mounted, and resets its error state whenever the route (pathname+search) changes so navigating away — back button included — recovers without a hard refresh. --- ui/src/components/Layout.tsx | 5 +- ui/src/components/RouteErrorBoundary.tsx | 73 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/RouteErrorBoundary.tsx diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a03400b1a85..390b9f441c3 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -17,6 +17,7 @@ import { MobileBottomNav } from "./MobileBottomNav"; import { WorktreeBanner } from "./WorktreeBanner"; import { DevRestartBanner } from "./DevRestartBanner"; import { StandaloneBrowserControls } from "./StandaloneBrowserControls"; +import { RouteErrorBoundary } from "./RouteErrorBoundary"; import { SidebarShell } from "./SidebarShell"; import { SecondarySidebar } from "./SecondarySidebar"; import { SidebarAccountMenu } from "./SidebarAccountMenu"; @@ -566,7 +567,9 @@ export function Layout() { requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix} /> ) : ( - + + + )} diff --git a/ui/src/components/RouteErrorBoundary.tsx b/ui/src/components/RouteErrorBoundary.tsx new file mode 100644 index 00000000000..69ab488445d --- /dev/null +++ b/ui/src/components/RouteErrorBoundary.tsx @@ -0,0 +1,73 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { useLocation, useNavigate } from "@/lib/router"; +import { Button } from "@/components/ui/button"; + +type RouteErrorBoundaryInnerProps = { + resetKey: string; + onReset: () => void; + children: ReactNode; +}; + +type RouteErrorBoundaryState = { + error: Error | null; +}; + +class RouteErrorBoundaryInner extends Component { + override state: RouteErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: unknown): RouteErrorBoundaryState { + return { error: error instanceof Error ? error : new Error(String(error)) }; + } + + override componentDidCatch(error: unknown, info: ErrorInfo): void { + console.error("Page render failed", { error, componentStack: info.componentStack }); + } + + override componentDidUpdate(prevProps: RouteErrorBoundaryInnerProps): void { + // A render throw with no boundary unmounts the whole app, so navigating + // away (back button included) can't recover without a hard refresh. We sit + // above the routed , so reset whenever the route changes. + if (this.state.error && prevProps.resetKey !== this.props.resetKey) { + this.setState({ error: null }); + } + } + + override render() { + const { error } = this.state; + if (!error) return this.props.children; + + return ( +
+
+

This page hit an error

+

+ Something went wrong while rendering this page. You can go back and try again, or reload. +

+
+
+          {error.message}
+        
+
+ + +
+
+ ); + } +} + +export function RouteErrorBoundary({ children }: { children: ReactNode }) { + const location = useLocation(); + const navigate = useNavigate(); + const resetKey = `${location.pathname}${location.search}`; + + return ( + navigate(-1)}> + {children} + + ); +}