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."} +
+ {!maxRetriesReached ? ( + + ) : ( + + )} +
+ ); + } + + return this.props.children; + } +}