diff --git a/CHANGELOG.md b/CHANGELOG.md
index b639176c..f4ddeea8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **No React error boundaries around lazy-loaded Explorer workspaces — a single render error crashed the whole app** (#768, #794) by @Sameer6305
+ - Added an `ErrorBoundary` class component (`explorer/src/ErrorBoundary.tsx`) and wrapped each lazy-loaded workspace's `` block in `App.tsx` with it, keyed on the active sub-view so navigating away from and back to a crashed tab remounts it cleanly
+ - Failed retries are capped at 3 before the fallback UI switches from "Try Again" to a "Reload Application" dead-end, preventing infinite retry loops on deterministic crashes; raw error/stack details are logged via `console.error` only and never rendered into the fallback UI
+ - Fixed the retry counter so it resets after a retry actually succeeds and stays error-free for a few seconds, instead of never resetting (which could permanently exhaust the retry budget on unrelated, individually-recoverable transient errors) or resetting on the very next commit (which could fire prematurely while `Suspense` was still showing its fallback)
+
- **Explorer frontend workspaces silently swallowed network/server errors** (#767, #790) by @Sameer6305
- `ShaclStudio.tsx`, `VersionsTab.tsx`, `SKOSVocabularyManager.tsx`, `EntityResolutionTab.tsx`, `LineageDiagram.tsx`, `DecisionWorkspace.tsx`, `KGOverviewTab.tsx`, `OntologyManager.tsx`, `OntologySearch.tsx`, `ReasoningWorkspace.tsx`, and `SparqlWorkspace.tsx` now render a visible error banner instead of only `console.error()`-ing failed fetches
- Added explicit `response.status === 207` (Multi-Status) handling across these workspaces so partial backend failures surface a warning instead of reading as a full success (`response.ok` is `true` for all 2xx codes, including 207)
diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx
index 3f2b7331..f3f97520 100644
--- a/explorer/src/App.tsx
+++ b/explorer/src/App.tsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense, useEffect, useState, type ReactNode } from 'react';
+import { lazy, Suspense, useEffect, useState, type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
ArrowRight,
@@ -16,6 +16,7 @@ import {
ShieldCheck,
type LucideIcon,
} from 'lucide-react';
+import { ErrorBoundary } from './ErrorBoundary';
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
@@ -1800,14 +1801,16 @@ export default function App() {
>
}
>
- }>
- {exploreView === 'graph' ? (
-
- ) : }
-
+
+ }>
+ {exploreView === 'graph' ? (
+
+ ) : }
+
+
);
}
@@ -1829,9 +1832,11 @@ export default function App() {
>
}
>
- }>
- {analyzeView === 'reasoning' ? : }
-
+
+ }>
+ {analyzeView === 'reasoning' ? : }
+
+
);
}
@@ -1843,9 +1848,11 @@ export default function App() {
subtitle="Inspect decision chains, causal context, and precedent matches."
kicker="Decision Intelligence"
>
- }>
-
-
+
+ }>
+
+
+
);
}
@@ -1873,12 +1880,14 @@ export default function App() {
>
}
>
- }>
- {enrichView === 'import' ? :
- enrichView === 'merge' ? :
- enrichView === 'resolve' ? :
- }
-
+
+ }>
+ {enrichView === 'import' ? :
+ enrichView === 'merge' ? :
+ enrichView === 'resolve' ? :
+ }
+
+
);
}
@@ -1891,15 +1900,17 @@ export default function App() {
kicker="Schema Governance"
compact
>
- }>
- {
- setGraphFocusRequest({ nodeId, token: Date.now() });
- setActiveWorkspace('explore');
- setExploreView('graph');
- }}
- />
-
+
+ }>
+ {
+ setGraphFocusRequest({ nodeId, token: Date.now() });
+ setActiveWorkspace('explore');
+ setExploreView('graph');
+ }}
+ />
+
+
);
}
@@ -1923,14 +1934,16 @@ export default function App() {
>
}
>
- }>
- {manageView === 'lineage' ? :
- manageView === 'kg-overview' ? :
- {
- setActiveWorkspace('explore');
- setExploreView('vocabulary');
- }} />}
-
+
+ }>
+ {manageView === 'lineage' ? :
+ manageView === 'kg-overview' ? :
+ {
+ setActiveWorkspace('explore');
+ setExploreView('vocabulary');
+ }} />}
+
+
);
};
diff --git a/explorer/src/ErrorBoundary.tsx b/explorer/src/ErrorBoundary.tsx
new file mode 100644
index 00000000..3d06af25
--- /dev/null
+++ b/explorer/src/ErrorBoundary.tsx
@@ -0,0 +1,112 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { AlertCircle } from 'lucide-react';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+ retryCount: number;
+}
+
+const RETRY_SETTLE_MS = 5000;
+
+export class ErrorBoundary extends Component {
+ private settleTimer: ReturnType | null = null;
+
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, error: null, retryCount: 0 };
+ }
+
+ static getDerivedStateFromError(error: Error): Partial {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error("ErrorBoundary caught an error:", error, errorInfo);
+ this.clearSettleTimer();
+ }
+
+ componentWillUnmount() {
+ this.clearSettleTimer();
+ }
+
+ private clearSettleTimer() {
+ if (this.settleTimer !== null) {
+ clearTimeout(this.settleTimer);
+ this.settleTimer = null;
+ }
+ }
+
+ resetErrorBoundary = () => {
+ this.clearSettleTimer();
+ this.setState((prev) => ({
+ hasError: false,
+ error: null,
+ retryCount: prev.retryCount + 1
+ }));
+ // Only clear the retry count once the workspace has stayed error-free for a
+ // sustained period, rather than on the next committed render (which can fire
+ // while Suspense is still showing its fallback) or immediately on retry
+ // (which would allow an unbounded number of clicks on a deterministic crash).
+ this.settleTimer = setTimeout(() => {
+ this.settleTimer = null;
+ this.setState({ retryCount: 0 });
+ }, RETRY_SETTLE_MS);
+ };
+
+ render() {
+ if (this.state.hasError) {
+ const maxRetriesReached = this.state.retryCount >= 3;
+
+ return (
+
+
+
+ Something went wrong in this view.
+
+
+ {maxRetriesReached
+ ? "This view continues to encounter a critical error. Please switch to another workspace or reload the page to restore functionality."
+ : "An unexpected problem occurred while rendering this workspace. Your data is safe, but this view cannot be displayed."}
+