From 9a21e523f0497cfe7a5489990e175f39206e71bc Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Fri, 24 Jul 2026 15:53:33 +0530 Subject: [PATCH 1/4] fix(#768): add ErrorBoundary to workspace Suspense blocks --- explorer/src/App.tsx | 89 ++++++++++++++++++------------- explorer/src/ErrorBoundary.tsx | 97 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 38 deletions(-) create mode 100644 explorer/src/ErrorBoundary.tsx 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..26aa9f12 --- /dev/null +++ b/explorer/src/ErrorBoundary.tsx @@ -0,0 +1,97 @@ +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; +} + +export class ErrorBoundary extends Component { + 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); + } + + componentDidUpdate() { + // If the component has successfully rendered (hasError is false) + // and we were previously tracking retries, reset the retry counter. + // This ensures a transient error that recovers doesn't permanently leave the + // component one strike closer to a hard failure. + if (!this.state.hasError && this.state.retryCount > 0) { + this.setState({ retryCount: 0 }); + } + } + + resetErrorBoundary = () => { + this.setState((prev) => ({ + hasError: false, + error: null, + retryCount: prev.retryCount + 1 + })); + }; + + 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; + } +} From d2d38a0509190664bfb36031139f0588a44e3811 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Fri, 24 Jul 2026 16:11:14 +0530 Subject: [PATCH 2/4] fix(#768): ensure ErrorBoundary retryCount only resets on recovery transition --- explorer/src/ErrorBoundary.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/explorer/src/ErrorBoundary.tsx b/explorer/src/ErrorBoundary.tsx index 26aa9f12..abaa924c 100644 --- a/explorer/src/ErrorBoundary.tsx +++ b/explorer/src/ErrorBoundary.tsx @@ -25,12 +25,12 @@ export class ErrorBoundary extends Component 0) { + // By checking prevState.hasError, we ensure this reset only triggers + // exactly once upon the recovery transition. + if (prevState.hasError && !this.state.hasError && this.state.retryCount > 0) { this.setState({ retryCount: 0 }); } } From adb46134c41905928e3fe50a7865975905a16cf8 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Fri, 24 Jul 2026 16:15:34 +0530 Subject: [PATCH 3/4] fix(#768): remove componentDidUpdate auto-reset to avoid premature reset on Suspense fallback --- explorer/src/ErrorBoundary.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/explorer/src/ErrorBoundary.tsx b/explorer/src/ErrorBoundary.tsx index abaa924c..94fdb876 100644 --- a/explorer/src/ErrorBoundary.tsx +++ b/explorer/src/ErrorBoundary.tsx @@ -25,15 +25,7 @@ export class ErrorBoundary extends Component 0) { - this.setState({ retryCount: 0 }); - } - } + resetErrorBoundary = () => { this.setState((prev) => ({ From 530e297d17dc36fe1eed09848ab4d419750a9a2b Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Sat, 25 Jul 2026 16:09:07 +0530 Subject: [PATCH 4/4] 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. --- CHANGELOG.md | 5 +++++ explorer/src/ErrorBoundary.tsx | 27 +++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8047c022..5e4ad863 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) + - **`tests/explorer/test_explorer_api.py` failed with `TypeError: Client.__init__() got an unexpected keyword argument 'app'` on current httpx** (#788, #789) by @Sameer6305 - `httpx>=0.28.0` removed the `app=` kwarg that Starlette's `TestClient` relies on to wrap a FastAPI app for testing; `httpx` wasn't pinned anywhere in `pyproject.toml`, so different environments could independently resolve an incompatible transitive version and hit the same break - Added an explicit `httpx<0.28.0` constraint to the main `[project.dependencies]` array (not just a dev extra), so it applies globally across production, dev, and CI installs diff --git a/explorer/src/ErrorBoundary.tsx b/explorer/src/ErrorBoundary.tsx index 94fdb876..3d06af25 100644 --- a/explorer/src/ErrorBoundary.tsx +++ b/explorer/src/ErrorBoundary.tsx @@ -11,7 +11,11 @@ interface ErrorBoundaryState { 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 }; @@ -23,16 +27,35 @@ export class ErrorBoundary extends Component { - this.setState((prev) => ({ - hasError: false, + 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() {