From d7a35b2d7adeb7cdd53c33a05b51ddf6be9f9710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 19:16:26 +0200 Subject: [PATCH] feat(web): add React error boundaries and Next.js error.tsx fallbacks Runtime errors in components now show a friendly "Something went wrong" screen instead of a white page. Timeline and staffing panel are individually wrapped. Route-level error.tsx handles server component errors. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/(app)/error.tsx | 60 +++++++++++++ apps/web/src/app/(app)/not-found.tsx | 35 ++++++++ apps/web/src/app/(app)/staffing/page.tsx | 7 +- apps/web/src/app/(app)/timeline/page.tsx | 5 +- apps/web/src/components/ui/ErrorBoundary.tsx | 88 ++++++++++++++++++++ 5 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(app)/error.tsx create mode 100644 apps/web/src/app/(app)/not-found.tsx create mode 100644 apps/web/src/components/ui/ErrorBoundary.tsx diff --git a/apps/web/src/app/(app)/error.tsx b/apps/web/src/app/(app)/error.tsx new file mode 100644 index 0000000..a617ee1 --- /dev/null +++ b/apps/web/src/app/(app)/error.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect } from "react"; + +export default function AppError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("[AppError]", error); + }, [error]); + + return ( +
+
+ + + +
+

+ Something went wrong +

+

+ {error.message || "An unexpected error occurred. The team has been notified."} +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/not-found.tsx b/apps/web/src/app/(app)/not-found.tsx new file mode 100644 index 0000000..79f9d02 --- /dev/null +++ b/apps/web/src/app/(app)/not-found.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+
+ + + +
+

+ Page not found +

+

+ The page you are looking for does not exist or has been moved. +

+ + Go to dashboard + +
+ ); +} diff --git a/apps/web/src/app/(app)/staffing/page.tsx b/apps/web/src/app/(app)/staffing/page.tsx index 2ad1a06..d91fedb 100644 --- a/apps/web/src/app/(app)/staffing/page.tsx +++ b/apps/web/src/app/(app)/staffing/page.tsx @@ -1,5 +1,10 @@ import { StaffingPanel } from "~/components/staffing/StaffingPanel.js"; +import { ErrorBoundary } from "~/components/ui/ErrorBoundary.js"; export default function StaffingPage() { - return ; + return ( + + + + ); } diff --git a/apps/web/src/app/(app)/timeline/page.tsx b/apps/web/src/app/(app)/timeline/page.tsx index d16dc8c..e5d4d1d 100644 --- a/apps/web/src/app/(app)/timeline/page.tsx +++ b/apps/web/src/app/(app)/timeline/page.tsx @@ -1,4 +1,5 @@ import dynamic from "next/dynamic"; +import { ErrorBoundary } from "~/components/ui/ErrorBoundary.js"; const TimelineView = dynamic( () => import("~/components/timeline/TimelineView.js").then((m) => m.TimelineView), @@ -22,7 +23,9 @@ export default function TimelinePage() {

Interactive resource planning timeline

- + + + ); } diff --git a/apps/web/src/components/ui/ErrorBoundary.tsx b/apps/web/src/components/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..c1e0314 --- /dev/null +++ b/apps/web/src/components/ui/ErrorBoundary.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Component, type ReactNode, type ErrorInfo } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, info: ErrorInfo) => void; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack); + this.props.onError?.(error, info); + } + + render() { + if (this.state.error) { + return ( + this.props.fallback ?? ( + this.setState({ error: null })} + /> + ) + ); + } + return this.props.children; + } +} + +export function DefaultErrorFallback({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + return ( +
+
+ + + +
+

+ Something went wrong +

+

+ {error.message || "An unexpected error occurred. The team has been notified."} +

+
+ + +
+
+ ); +}