From 7582ba7cfd452c47fec74a43f2acf5f416a9bb30 Mon Sep 17 00:00:00 2001 From: Carlsaurus Date: Tue, 8 Sep 2026 00:03:17 -0700 Subject: [PATCH] Stop a render error from blanking the whole app The frontend had NO error boundary anywhere -- grep for ErrorBoundary / componentDidCatch / getDerivedStateFromError returned nothing. In React that means any exception thrown during render unmounts the ENTIRE tree, so every render bug presents identically: a white page, no message, no stack, no way to report what happened. That is how "press Optimize on the flight page and it goes blank" arrived with nothing to act on. Adds components/ErrorBoundary.tsx and wraps each of the nine tab panels in one. The tab buttons live in
, above the panels, so a crashing tab now shows a copyable error with its component stack and a Try again button while the tab bar stays alive and every other tab keeps working. Verified end to end, not just by inspection: injected a throw at the top of FlightSimulation, reloaded, and confirmed the boundary rendered "Flight Simulation hit an error" with the stack while the rest of the app -- header, all nine tabs, the other panels -- stayed mounted and usable. Throw reverted. Note this does not by itself remove the underlying throw on the flight optimize path. I could not reproduce that one: the handler is correctly guarded (checks result.error, checks result.data, wrapped in try/catch -- though try/catch does not cover render), and every field the flight UI renders is a REQUIRED float or List[float] in the backend models, with all Optional sub-objects already guarded (results?.truncation?., results.propellant &&, and the !results?.trajectory early return). With the boundary in place the next occurrence prints the real error instead of a blank page, which is what makes it fixable. The Try again button is declared VIEW_ONLY in the checkout gating audit: it clears local error state and touches no design state. Frontend gating audit 4 passed; npm run build clean. Co-Authored-By: Claude Opus 5 --- EngineDesign/frontend/src/App.tsx | 185 ++++++++++-------- .../frontend/src/components/ErrorBoundary.tsx | 75 +++++++ EngineDesign/frontend/src/lib/gating.test.ts | 1 + 3 files changed, 178 insertions(+), 83 deletions(-) create mode 100644 EngineDesign/frontend/src/components/ErrorBoundary.tsx diff --git a/EngineDesign/frontend/src/App.tsx b/EngineDesign/frontend/src/App.tsx index a509dd39..2feac84d 100644 --- a/EngineDesign/frontend/src/App.tsx +++ b/EngineDesign/frontend/src/App.tsx @@ -13,6 +13,7 @@ import ConfigurationSelector from './components/ConfigurationSelector'; import { emitConfigChanged } from './lib/configBus'; import { useViewState } from './lib/viewState'; import { DesignVersions } from './components/DesignVersions'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { ReadOnlyProvider } from '@stardesign-ui'; import { getConfig, getHealth } from './api/client'; import type { EngineConfig } from './api/client'; @@ -237,117 +238,135 @@ function App() { {/* Keep all tab panels mounted; hide inactive ones to preserve state */}
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
- + + +
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {!config && ( -
-

Load Configuration

- -
- )} - -
+ +
+ {!config && ( +
+

Load Configuration

+ +
+ )} + +
+
-
- {/* Upload section - compact */} -
-
-
- -
- {config && ( -
- - - - Config loaded and ready + +
+ {/* Upload section - compact */} +
+
+
+
- )} + {config && ( +
+ + + + Config loaded and ready +
+ )} +
-
- {/* Editor section - full width */} -
- + {/* Editor section - full width */} +
+ +
-
+
diff --git a/EngineDesign/frontend/src/components/ErrorBoundary.tsx b/EngineDesign/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..d9e2e1d3 --- /dev/null +++ b/EngineDesign/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,75 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +/** + * Catches render-time exceptions so one bad value cannot blank the whole app. + * + * Without a boundary anywhere in the tree, React unmounts EVERYTHING when a + * render throws -- the user sees a white page with no message, no stack, and no + * way to report what happened. Every render bug then looks identical, which is + * exactly how "press Optimize, page goes blank" got reported with nothing to go + * on. This keeps the failure on screen and legible instead. + */ +interface Props { + children: ReactNode; + /** Shown above the error, e.g. "Flight Simulation". */ + label?: string; +} +interface State { + error: Error | null; + info: ErrorInfo | null; +} + +export class ErrorBoundary extends Component { + state: State = { error: null, info: null }; + + static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // Keep the console record: the boundary stops the crash from propagating, + // so without this the stack would be swallowed entirely. + console.error('[ErrorBoundary]', this.props.label ?? '', error, info.componentStack); + this.setState({ info }); + } + + private reset = () => this.setState({ error: null, info: null }); + + render() { + const { error, info } = this.state; + if (!error) return this.props.children; + + const detail = [error.stack || String(error), info?.componentStack] + .filter(Boolean) + .join('\n\nComponent stack:'); + + return ( +
+

+ {this.props.label ? `${this.props.label} hit an error` : 'Something went wrong'} +

+

+ The rest of the app is still running. Copy the detail below when reporting this. +

+

{String(error.message || error)}

+
+ + Show stack + +
+            {detail}
+          
+
+ +
+ ); + } +} + +export default ErrorBoundary; diff --git a/EngineDesign/frontend/src/lib/gating.test.ts b/EngineDesign/frontend/src/lib/gating.test.ts index 4aa7f619..74474c97 100644 --- a/EngineDesign/frontend/src/lib/gating.test.ts +++ b/EngineDesign/frontend/src/lib/gating.test.ts @@ -79,6 +79,7 @@ const VIEW_ONLY: Record = { 'ConfigEditor.tsx:setSearchQuery': 'filters which sections are shown', 'ConfigEditor.tsx:setIsExpanded': 'expand/collapse a section', 'ConfigUpload.tsx:label': 'the drop zone wrapper, not a control', + 'ErrorBoundary.tsx:this.reset': 'clears a caught render error; touches no design state', 'Layer1Optimization.tsx:setShowParameterPlots': 'chart visibility', 'Layer1Optimization.tsx:setShowInjectorPressures': 'chart visibility', 'Layer1Optimization.tsx:setShowSolverInputsEcho': 'diagnostics visibility',