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 (
+
+ );
}
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();
+ });
+});