From 37de6d54b6d9912e325597e075c82ccda3d707de Mon Sep 17 00:00:00 2001 From: Ssoleed Date: Thu, 30 Jul 2026 09:40:32 +0100 Subject: [PATCH] Add global error and not-found pages --- app/global-error.tsx | 23 ++++ app/layout.tsx | 13 ++- app/not-found.tsx | 10 ++ components/ui/GlobalErrorBoundary.tsx | 44 ++++++++ components/ui/RouteStateCard.tsx | 24 ++++ .../ui/__tests__/GlobalErrorBoundary.test.tsx | 33 ++++++ .../ui/__tests__/RouteStateCard.test.tsx | 23 ++++ package-lock.json | 105 ++++++++++++++++++ 8 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 app/global-error.tsx create mode 100644 app/not-found.tsx create mode 100644 components/ui/GlobalErrorBoundary.tsx create mode 100644 components/ui/RouteStateCard.tsx create mode 100644 components/ui/__tests__/GlobalErrorBoundary.test.tsx create mode 100644 components/ui/__tests__/RouteStateCard.test.tsx diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..462a96d --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { RouteStateCard } from "@/components/ui/RouteStateCard"; +import { logError } from "@/lib/logger"; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + logError("Unhandled render error", { error }); + + return ( + + + + + + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index d6e1076..d9d6752 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Providers } from "@/components/providers"; import { Navbar } from "@/components/layout"; import { Toaster } from "@/components/ui/sonner"; +import { GlobalErrorBoundary } from "@/components/ui/GlobalErrorBoundary"; import "./globals.css"; export const metadata: Metadata = { @@ -17,11 +18,13 @@ export default function RootLayout({ return ( - - - {children} - - + + + + {children} + + + ); diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..3bd34e3 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,10 @@ +import { RouteStateCard } from "@/components/ui/RouteStateCard"; + +export default function NotFound() { + return ( + + ); +} diff --git a/components/ui/GlobalErrorBoundary.tsx b/components/ui/GlobalErrorBoundary.tsx new file mode 100644 index 0000000..8680ae9 --- /dev/null +++ b/components/ui/GlobalErrorBoundary.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { RouteStateCard } from "@/components/ui/RouteStateCard"; +import { logError } from "@/lib/logger"; + +interface GlobalErrorBoundaryProps { + children: ReactNode; +} + +interface GlobalErrorBoundaryState { + hasError: boolean; +} + +export class GlobalErrorBoundary extends Component< + GlobalErrorBoundaryProps, + GlobalErrorBoundaryState +> { + constructor(props: GlobalErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): GlobalErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + logError("Unhandled render error", { error, errorInfo }); + } + + render(): ReactNode { + if (this.state.hasError) { + return ( + + ); + } + + return this.props.children; + } +} diff --git a/components/ui/RouteStateCard.tsx b/components/ui/RouteStateCard.tsx new file mode 100644 index 0000000..7c0c650 --- /dev/null +++ b/components/ui/RouteStateCard.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface RouteStateCardProps { + title: string; + message: string; +} + +export function RouteStateCard({ title, message }: RouteStateCardProps) { + return ( +
+
+

+ Oops +

+

{title}

+

{message}

+ +
+
+ ); +} diff --git a/components/ui/__tests__/GlobalErrorBoundary.test.tsx b/components/ui/__tests__/GlobalErrorBoundary.test.tsx new file mode 100644 index 0000000..769ca2b --- /dev/null +++ b/components/ui/__tests__/GlobalErrorBoundary.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { GlobalErrorBoundary } from "../GlobalErrorBoundary"; +import * as logger from "@/lib/logger"; + +function BrokenComponent() { + throw new Error("boom"); +} + +describe("GlobalErrorBoundary", () => { + beforeEach(() => { + vi.spyOn(logger, "logError").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders the fallback page and logs the error", () => { + render( + + + + ); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /go to marketplace/i })).toHaveAttribute( + "href", + "/marketplace" + ); + expect(logger.logError).toHaveBeenCalled(); + }); +}); diff --git a/components/ui/__tests__/RouteStateCard.test.tsx b/components/ui/__tests__/RouteStateCard.test.tsx new file mode 100644 index 0000000..8bb764e --- /dev/null +++ b/components/ui/__tests__/RouteStateCard.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RouteStateCard } from "../RouteStateCard"; + +describe("RouteStateCard", () => { + it("renders the title, message, and marketplace link", () => { + render( + + ); + + expect(screen.getByText("Page not found")).toBeInTheDocument(); + expect( + screen.getByText("The page you are looking for does not exist.") + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /go to marketplace/i })).toHaveAttribute( + "href", + "/marketplace" + ); + }); +}); diff --git a/package-lock.json b/package-lock.json index bdcd087..ebc46ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9951,6 +9951,111 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", + "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", + "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", + "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", + "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", + "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", + "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", + "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } }