fix(#768): Prevent application crashes by wrapping workspaces in Error Boundaries (#794)

* fix(#768): add ErrorBoundary to workspace Suspense blocks

* fix(#768): ensure ErrorBoundary retryCount only resets on recovery transition

* fix(#768): remove componentDidUpdate auto-reset to avoid premature reset on Suspense fallback

* fix(#768): reset ErrorBoundary retryCount only after a retry settles

Previously retryCount never reset on success (removed in adb4613 to
avoid resetting mid-Suspense-fallback), so unrelated transient errors
across a session could permanently exhaust the 3-retry budget even
though each prior retry had actually recovered. Now the counter resets
via a short settle timer after a retry stays error-free, avoiding both
the premature-reset and never-reset failure modes.

---------

Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
This commit is contained in:
Mohd Kaif
2026-07-25 16:15:21 +05:30
committed by GitHub
co-authored by KaifAhmad1
3 changed files with 168 additions and 38 deletions
+5
View File
@@ -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 `<Suspense>` 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)
+51 -38
View File
@@ -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() {
</>
}
>
<Suspense fallback={<WorkspaceFallback />}>
{exploreView === 'graph' ? (
<GraphWorkspace
externalFocusNodeId={graphFocusRequest?.nodeId}
externalFocusToken={graphFocusRequest?.token}
/>
) : <VocabularyWorkspace />}
</Suspense>
<ErrorBoundary key={`explore-${exploreView}`}>
<Suspense fallback={<WorkspaceFallback />}>
{exploreView === 'graph' ? (
<GraphWorkspace
externalFocusNodeId={graphFocusRequest?.nodeId}
externalFocusToken={graphFocusRequest?.token}
/>
) : <VocabularyWorkspace />}
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
);
}
@@ -1829,9 +1832,11 @@ export default function App() {
</>
}
>
<Suspense fallback={<WorkspaceFallback />}>
{analyzeView === 'reasoning' ? <ReasoningWorkspace /> : <SparqlWorkspace />}
</Suspense>
<ErrorBoundary key={`analyze-${analyzeView}`}>
<Suspense fallback={<WorkspaceFallback />}>
{analyzeView === 'reasoning' ? <ReasoningWorkspace /> : <SparqlWorkspace />}
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
);
}
@@ -1843,9 +1848,11 @@ export default function App() {
subtitle="Inspect decision chains, causal context, and precedent matches."
kicker="Decision Intelligence"
>
<Suspense fallback={<WorkspaceFallback />}>
<DecisionWorkspace />
</Suspense>
<ErrorBoundary key="decisions">
<Suspense fallback={<WorkspaceFallback />}>
<DecisionWorkspace />
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
);
}
@@ -1873,12 +1880,14 @@ export default function App() {
</>
}
>
<Suspense fallback={<WorkspaceFallback />}>
{enrichView === 'import' ? <ImportExportWorkspace /> :
enrichView === 'merge' ? <DiffMergeWorkspace /> :
enrichView === 'resolve' ? <EntityResolutionTab /> :
<RegistryTab />}
</Suspense>
<ErrorBoundary key={`enrich-${enrichView}`}>
<Suspense fallback={<WorkspaceFallback />}>
{enrichView === 'import' ? <ImportExportWorkspace /> :
enrichView === 'merge' ? <DiffMergeWorkspace /> :
enrichView === 'resolve' ? <EntityResolutionTab /> :
<RegistryTab />}
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
);
}
@@ -1891,15 +1900,17 @@ export default function App() {
kicker="Schema Governance"
compact
>
<Suspense fallback={<WorkspaceFallback />}>
<OntologyWorkspace
onJumpToGraphNode={(nodeId: string) => {
setGraphFocusRequest({ nodeId, token: Date.now() });
setActiveWorkspace('explore');
setExploreView('graph');
}}
/>
</Suspense>
<ErrorBoundary key="ontology-hub">
<Suspense fallback={<WorkspaceFallback />}>
<OntologyWorkspace
onJumpToGraphNode={(nodeId: string) => {
setGraphFocusRequest({ nodeId, token: Date.now() });
setActiveWorkspace('explore');
setExploreView('graph');
}}
/>
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
);
}
@@ -1923,14 +1934,16 @@ export default function App() {
</>
}
>
<Suspense fallback={<WorkspaceFallback />}>
{manageView === 'lineage' ? <LineageDiagram /> :
manageView === 'kg-overview' ? <KGOverviewTab /> :
<OntologySummaryTab onOpenVocabularyBrowser={() => {
setActiveWorkspace('explore');
setExploreView('vocabulary');
}} />}
</Suspense>
<ErrorBoundary key={`manage-${manageView}`}>
<Suspense fallback={<WorkspaceFallback />}>
{manageView === 'lineage' ? <LineageDiagram /> :
manageView === 'kg-overview' ? <KGOverviewTab /> :
<OntologySummaryTab onOpenVocabularyBrowser={() => {
setActiveWorkspace('explore');
setExploreView('vocabulary');
}} />}
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
);
};
+112
View File
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
private settleTimer: ReturnType<typeof setTimeout> | null = null;
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, retryCount: 0 };
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
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 (
<div
className="workspace-loading"
style={{
flexDirection: 'column',
gap: 12,
color: 'var(--ws-red)'
}}
>
<AlertCircle size={32} style={{ marginBottom: 4, opacity: 0.8 }} />
<div style={{ fontWeight: 500, fontSize: '15px' }}>
Something went wrong in this view.
</div>
<div style={{ fontSize: '13px', opacity: 0.7, maxWidth: 450, textAlign: 'center', marginBottom: 8, lineHeight: 1.5 }}>
{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."}
</div>
{!maxRetriesReached ? (
<button
className="ws-btn ws-btn--ghost"
style={{
borderColor: 'var(--ws-red-soft)',
color: 'var(--ws-red)'
}}
onClick={this.resetErrorBoundary}
>
Try Again
</button>
) : (
<button
className="ws-btn ws-btn--ghost"
style={{
borderColor: 'var(--ws-border)',
color: 'var(--ws-text)'
}}
onClick={() => window.location.reload()}
>
Reload Application
</button>
)}
</div>
);
}
return this.props.children;
}
}