From 82748e482bca187bbb742cd4a47c28e3b811f80c Mon Sep 17 00:00:00 2001 From: shivv23 Date: Fri, 22 May 2026 15:51:31 +0530 Subject: [PATCH] feat: add per-route error boundaries and PageLoader component --- tensormap-frontend/src/App.jsx | 64 +++++----- .../src/components/ErrorBoundary.jsx | 56 +++++++-- .../src/components/ErrorBoundary.test.jsx | 109 ++++++++++++++++++ 3 files changed, 186 insertions(+), 43 deletions(-) create mode 100644 tensormap-frontend/src/components/ErrorBoundary.test.jsx diff --git a/tensormap-frontend/src/App.jsx b/tensormap-frontend/src/App.jsx index 463e7ce5..176864ea 100644 --- a/tensormap-frontend/src/App.jsx +++ b/tensormap-frontend/src/App.jsx @@ -1,7 +1,7 @@ import { Suspense, lazy } from "react"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import AppTopBar from "./components/AppTopBar"; -import ErrorBoundary from "./components/ErrorBoundary"; +import ErrorBoundary, { PageLoader } from "./components/ErrorBoundary"; import WorkspaceLayout from "./components/Workspace/WorkspaceLayout"; const ProjectsPage = lazy(() => import("./containers/ProjectsPage/ProjectsPage")); @@ -10,47 +10,49 @@ const DataProcess = lazy(() => import("./containers/DataProcess/DataProcess")); const DeepLearning = lazy(() => import("./containers/DeepLearning/DeepLearning")); const Training = lazy(() => import("./containers/Training/Training")); +const withErrorBoundary = (Component) => ( + + + +); + /** * Root application component that defines all client-side routes. * - * Uses lazy-loaded page components wrapped in an ErrorBoundary and a Suspense - * fallback. Routes are organised into a projects listing and a per-project - * workspace layout with nested dataset, process, model, and training pages. + * Uses lazy-loaded page components wrapped in per-route ErrorBoundary components + * and a Suspense fallback. Each route is isolated — if one page crashes, the + * others remain functional. */ function App() { return ( - - Loading...} - > - - {/* Projects landing page */} - } /> + }> + + {/* Projects landing page */} + - {/* Workspace with sidebar */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* Workspace with sidebar */} + }> + } /> + + } /> + + + + - {/* Legacy redirects */} - } /> - } /> - } /> - } /> - } /> + {/* Legacy redirects */} + } /> + } /> + } /> + } /> + } /> - {/* Catch-all */} - } /> - - - + {/* Catch-all */} + } /> + + ); } diff --git a/tensormap-frontend/src/components/ErrorBoundary.jsx b/tensormap-frontend/src/components/ErrorBoundary.jsx index 207361c5..9017ce1d 100644 --- a/tensormap-frontend/src/components/ErrorBoundary.jsx +++ b/tensormap-frontend/src/components/ErrorBoundary.jsx @@ -1,4 +1,5 @@ import { Component } from "react"; +import { useNavigate } from "react-router-dom"; /** * React Error Boundary that catches rendering errors in its subtree and @@ -18,25 +19,56 @@ class ErrorBoundary extends Component { console.error("ErrorBoundary caught an error:", error, info); } + handleRetry = () => { + this.setState({ hasError: false, error: null }); + }; + render() { if (this.state.hasError) { - return ( -
-

Something went wrong

-

- An unexpected error occurred. Please refresh the page or try again later. -

+ return ; + } + return this.props.children; + } +} + +export function ErrorFallback({ error, onRetry }) { + const navigate = useNavigate(); + + return ( +
+

Something went wrong

+

+ {error?.message || "An unexpected error occurred. Please try again."} +

+
+ {onRetry && ( -
- ); - } - return this.props.children; - } + )} + +
+
+ ); +} + +export function PageLoader() { + return ( +
+
+
+ Loading page... +
+
+ ); } export default ErrorBoundary; diff --git a/tensormap-frontend/src/components/ErrorBoundary.test.jsx b/tensormap-frontend/src/components/ErrorBoundary.test.jsx new file mode 100644 index 00000000..90a04651 --- /dev/null +++ b/tensormap-frontend/src/components/ErrorBoundary.test.jsx @@ -0,0 +1,109 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi } from "vitest"; +import ErrorBoundary, { ErrorFallback, PageLoader } from "./ErrorBoundary"; + +describe("ErrorFallback", () => { + it("renders default message when no error provided", () => { + render( + + + , + ); + expect(screen.getByText(/something went wrong/i)).toBeDefined(); + }); + + it("renders error message when error is provided", () => { + render( + + + , + ); + expect(screen.getByText("Test error message")).toBeDefined(); + }); + + it("shows try again button when onRetry is provided", () => { + render( + + {}} /> + , + ); + expect(screen.getByText("Try again")).toBeDefined(); + }); + + it("hides try again button when onRetry is not provided", () => { + render( + + + , + ); + expect(screen.queryByText("Try again")).toBeNull(); + }); + + it("renders back to projects link", () => { + render( + + + , + ); + expect(screen.getByText("Back to Projects")).toBeDefined(); + }); +}); + +describe("PageLoader", () => { + it("renders loading spinner and text", () => { + render(); + expect(screen.getByText("Loading page...")).toBeDefined(); + }); +}); + +describe("ErrorBoundary", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders children when there is no error", () => { + render( + + +
Child content
+
+
, + ); + expect(screen.getByText("Child content")).toBeDefined(); + }); + + it("renders error fallback when a child throws", () => { + const ThrowingComponent = () => { + throw new Error("Boom!"); + }; + + render( + + + + + , + ); + + expect(screen.getByText(/something went wrong/i)).toBeDefined(); + expect(screen.getByText("Boom!")).toBeDefined(); + }); + + it("recovers after retry resets state", () => { + const ErrorBoundaryInstance = new ErrorBoundary({ children: null }); + + expect(ErrorBoundaryInstance.state.hasError).toBe(false); + + ErrorBoundaryInstance.setState({ hasError: true, error: new Error("test") }); + + ErrorBoundaryInstance.handleRetry(); + + expect(ErrorBoundaryInstance.state.hasError).toBe(false); + expect(ErrorBoundaryInstance.state.error).toBeNull(); + }); +});