Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/web/src/base/ErrorState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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;
icon?: ReactNode;
message?: string;
};

const iconColors: Record<ErrorStateColor, string> = {
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`, and override the default icon with `icon` when a different visual
* fits.
*/
export default function ErrorState({
children,
className,
color = "red",
icon = (
<CircleAlert
className={cn("size-10", iconColors[color])}
aria-hidden="true"
/>
),
message = "Something went wrong",
}: ErrorStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center h-96 gap-4",
className,
)}
>
{icon}
<strong className="text-base">{message}</strong>
{children}
</div>
);
}
6 changes: 3 additions & 3 deletions apps/web/src/base/RouteError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -74,11 +75,10 @@ export default function RouteError({ error }: ErrorComponentProps) {
}

return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<strong className="text-base">Something went wrong</strong>
<ErrorState>
<Button color="blue" onClick={() => router.invalidate()}>
Try again
</Button>
</div>
</ErrorState>
);
}
61 changes: 61 additions & 0 deletions apps/web/src/base/__tests__/ErrorState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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(<ErrorState />);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});

it("should render a custom message", () => {
render(<ErrorState message="Couldn't load samples" />);
expect(screen.getByText("Couldn't load samples")).toBeInTheDocument();
});

it("should apply the default red icon color", () => {
const { container } = render(<ErrorState />);
expect(container.querySelector("svg")).toHaveClass("text-red-500");
});

it("should apply the requested palette color to the icon", () => {
const { container } = render(<ErrorState color="orange" />);
expect(container.querySelector("svg")).toHaveClass("text-orange-500");
});

it("should hide the decorative default icon from assistive tech", () => {
const { container } = render(<ErrorState />);
expect(container.querySelector("svg")).toHaveAttribute(
"aria-hidden",
"true",
);
});

it("should render a custom icon in place of the default", () => {
const { container } = render(
<ErrorState icon={<svg data-testid="custom-icon" />} />,
);
expect(
container.querySelector('[data-testid="custom-icon"]'),
).toBeInTheDocument();
expect(
container.querySelector(".lucide-circle-alert"),
).not.toBeInTheDocument();
});

it("should render children as the action", () => {
render(
<ErrorState>
<button type="button">Try again</button>
</ErrorState>,
);
expect(
screen.getByRole("button", { name: "Try again" }),
).toBeInTheDocument();
});

it("should merge a custom className", () => {
const { container } = render(<ErrorState className="custom-class" />);
expect(container.firstChild).toHaveClass("custom-class");
});
});
2 changes: 2 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -34,6 +35,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
shellComponent: RootShell,
component: RootComponent,
pendingComponent: LoadingPlaceholder,
errorComponent: RouteError,
notFoundComponent: NotFoundComponent,
});

Expand Down
13 changes: 9 additions & 4 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,15 @@ For the resource a route exists to show (detail pages, the record the URL is

Loading is absorbed by the `<Suspense>` boundary already wrapping the
authenticated `<Outlet>`. 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
Expand Down