mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* 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:
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user