From 44f31dfb64eaf1d03d272954d7c854cd2a4e4ccd Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 2 Jul 2026 16:31:46 -0700 Subject: [PATCH 1/2] feat: show a shared error state when a page fails to load Add a reusable ErrorState component (icon, message, optional action) and render it from RouteError's retryable branch. Wire an errorComponent on the root route so failures in the root shell are caught too. --- apps/web/src/base/ErrorState.tsx | 49 +++++++++++++++++++ apps/web/src/base/RouteError.tsx | 6 +-- .../src/base/__tests__/ErrorState.test.tsx | 41 ++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + docs/queries.md | 13 +++-- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/base/ErrorState.tsx create mode 100644 apps/web/src/base/__tests__/ErrorState.test.tsx diff --git a/apps/web/src/base/ErrorState.tsx b/apps/web/src/base/ErrorState.tsx new file mode 100644 index 000000000..75446fc69 --- /dev/null +++ b/apps/web/src/base/ErrorState.tsx @@ -0,0 +1,49 @@ +import { cn } from "@app/utils"; +import { CircleAlert } from "lucide-react"; +import type { ReactNode } from "react"; + +type ErrorStateColor = "blue" | "green" | "gray" | "orange" | "purple" | "red"; + +type ErrorStateProps = { + children?: ReactNode; + className?: string; + color?: ErrorStateColor; + message?: string; +}; + +const iconColors: Record = { + blue: "text-blue-500", + green: "text-green-500", + gray: "text-gray-500", + orange: "text-orange-500", + purple: "text-purple-500", + red: "text-red-500", +}; + +/** + * A centered error state with an icon, message, and optional action. + * + * The shared primitive behind the route-level `RouteError` retryable branch and + * any view that needs a generic "something went wrong" fallback in place of its + * content. Pass the recovery affordance (e.g. a "Try again" button) as + * `children`. + */ +export default function ErrorState({ + children, + className, + color = "red", + message = "Something went wrong", +}: ErrorStateProps) { + return ( +
+ + {message} + {children} +
+ ); +} diff --git a/apps/web/src/base/RouteError.tsx b/apps/web/src/base/RouteError.tsx index fab38cd07..badf09be8 100644 --- a/apps/web/src/base/RouteError.tsx +++ b/apps/web/src/base/RouteError.tsx @@ -2,6 +2,7 @@ import { useQueryErrorResetBoundary } from "@tanstack/react-query"; import { type ErrorComponentProps, useRouter } from "@tanstack/react-router"; import { useEffect } from "react"; import Button from "./Button"; +import ErrorState from "./ErrorState"; import NotFound from "./NotFound"; function getStatus(error: unknown): number | undefined { @@ -74,11 +75,10 @@ export default function RouteError({ error }: ErrorComponentProps) { } return ( -
- Something went wrong + -
+ ); } diff --git a/apps/web/src/base/__tests__/ErrorState.test.tsx b/apps/web/src/base/__tests__/ErrorState.test.tsx new file mode 100644 index 000000000..75ea778d2 --- /dev/null +++ b/apps/web/src/base/__tests__/ErrorState.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import ErrorState from "../ErrorState"; + +describe("ErrorState", () => { + it("should render the default message", () => { + render(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("should render a custom message", () => { + render(); + expect(screen.getByText("Couldn't load samples")).toBeInTheDocument(); + }); + + it("should apply the default red icon color", () => { + const { container } = render(); + expect(container.querySelector("svg")).toHaveClass("text-red-500"); + }); + + it("should apply the requested palette color to the icon", () => { + const { container } = render(); + expect(container.querySelector("svg")).toHaveClass("text-orange-500"); + }); + + it("should render children as the action", () => { + render( + + + , + ); + expect( + screen.getByRole("button", { name: "Try again" }), + ).toBeInTheDocument(); + }); + + it("should merge a custom className", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("custom-class"); + }); +}); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index cd1707ef3..5b224c415 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,6 +1,7 @@ import "@app/style.css"; import LoadingPlaceholder from "@base/LoadingPlaceholder"; import NotFound from "@base/NotFound"; +import RouteError from "@base/RouteError"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRootRouteWithContext, @@ -34,6 +35,7 @@ export const Route = createRootRouteWithContext()({ shellComponent: RootShell, component: RootComponent, pendingComponent: LoadingPlaceholder, + errorComponent: RouteError, notFoundComponent: NotFoundComponent, }); diff --git a/docs/queries.md b/docs/queries.md index c2b2dab96..1a1a23edc 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -138,10 +138,15 @@ For the resource a route exists to show (detail pages, the record the URL is Loading is absorbed by the `` boundary already wrapping the authenticated ``. Errors are caught by the router's -`defaultErrorComponent` (`@base/RouteError`), wired once in `router.tsx`: it -reads the HTTP status off the error and renders a permission message for 403, -a not-found for 404, and a retryable message otherwise. A route only needs its -own `errorComponent` when it wants bespoke copy. +`defaultErrorComponent` (`@base/RouteError`), wired once in `router.tsx`, and +by the root route's own `errorComponent` (also `@base/RouteError`, in +`routes/__root.tsx`) which catches failures in the root shell that the default +wouldn't. `RouteError` reads the HTTP status off the error and renders a +permission message for 403, a not-found for 404, and otherwise the shared +`@base/ErrorState` primitive (a centered icon + message + "Try again" action). +A route only needs its own `errorComponent` when it wants bespoke copy; reach +for `@base/ErrorState` directly when a non-route view needs the same generic +"something went wrong" fallback. Keep a loader's `404 → notFound()` mapping where it exists — that routes a missing record to the dedicated `notFoundComponent` rather than the error From 9463b780319bb197e3e9bae6acc2cb9626192486 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 2 Jul 2026 16:46:20 -0700 Subject: [PATCH 2/2] fix: make the error state icon accessible and customizable Mark the default error icon aria-hidden since the message conveys the meaning, and add an optional icon prop so other views can supply a different visual. --- apps/web/src/base/ErrorState.tsx | 12 +++++++++-- .../src/base/__tests__/ErrorState.test.tsx | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/web/src/base/ErrorState.tsx b/apps/web/src/base/ErrorState.tsx index 75446fc69..d6cdd08de 100644 --- a/apps/web/src/base/ErrorState.tsx +++ b/apps/web/src/base/ErrorState.tsx @@ -8,6 +8,7 @@ type ErrorStateProps = { children?: ReactNode; className?: string; color?: ErrorStateColor; + icon?: ReactNode; message?: string; }; @@ -26,12 +27,19 @@ const iconColors: Record = { * The shared primitive behind the route-level `RouteError` retryable branch and * any view that needs a generic "something went wrong" fallback in place of its * content. Pass the recovery affordance (e.g. a "Try again" button) as - * `children`. + * `children`, and override the default icon with `icon` when a different visual + * fits. */ export default function ErrorState({ children, className, color = "red", + icon = ( +