Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 47 additions & 39 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import { LoadingFallback } from "./components/LoadingFallback";
import { GlobalErrorBoundary } from "./components/ErrorBoundary";
import { GlobalErrorBoundary, withRouteErrorBoundary } from "./components/ErrorBoundary";
import { NotificationProvider } from "./context/NotificationContext";
import { useNotifications } from "./hooks/useNotifications";

/** Wrap a top-level page element in a route error boundary. */
const routePage = withRouteErrorBoundary;

const Dashboard = lazy(() => import("./pages/Dashboard"));
const AssetDetail = lazy(() => import("./pages/AssetDetail"));
const Bridges = lazy(() => import("./pages/Bridges"));
Expand Down Expand Up @@ -57,46 +60,51 @@ function App() {
<NotificationInitializer />
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/" element={routePage(<Landing />, "Route:/")} />

<Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/assets/:symbol" element={<AssetDetail />} />
<Route path="/bridges" element={<Bridges />} />
<Route path="/incidents" element={<Incidents />} />
<Route path="/incidents/replay/:id" element={<IncidentReplay />} />
<Route path="/alerts" element={<Alerts />} />
<Route path="/alert-playbooks" element={<AlertPlaybookViewer />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/analytics/metric-builder" element={<CustomMetricBuilder />} />
<Route path="/reports" element={<Reports />} />
<Route path="/watchlist" element={<WatchlistPage />} />
<Route path="/watchlists" element={<WatchlistsPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin/api-keys" element={<ApiKeys />} />
<Route path="/admin/alert-routing" element={<AlertRoutingAdmin />} />
<Route path="/admin/access-audit" element={<OperationalAccessAudit />} />
<Route path="/supply-chain" element={<SupplyChain />} />
<Route path="/bridge-topology" element={<BridgeTopologyExplorer />} />
<Route path="/reconciliation" element={<Reconciliation />} />
<Route path="/api-docs" element={<ApiDocs />} />
<Route path="/help" element={<Help />} />
<Route path="/release-notes" element={<ReleaseNotes />} />
<Route path="/notification-preferences" element={<NotificationPreferencesPage />} />
<Route path="/relationship-explorer" element={<RelationshipExplorer />} />
<Route path="/search" element={<SearchResultsPage />} />
<Route path="/data-provenance" element={<DataProvenanceGraph />} />
<Route path="/alert-sandbox" element={<AlertSimulationSandbox />} />
<Route path="/liquidity-fragmentation" element={<LiquidityFragmentation />} />
<Route path="/liquidity-dashboard" element={<LiquidityDashboard />} />
<Route path="/schema-drift" element={<SchemaDriftMonitor />} />
<Route path="/bridge-health-timeline" element={<BridgeHealthTimeline />} />
<Route path="/export-scheduler" element={<ExportScheduler />} />
<Route path="/asset-comparison" element={<AssetComparison />} />
<Route path="/metrics-sidebar" element={<MetricsSidebarPage />} />
<Route path="/cross-chain-verification" element={<CrossChainVerification />} />
<Route path="/freshness" element={<FreshnessMonitoring />} />
{/*
Each top-level page is wrapped so a render error stays on that route.
Layout also keeps a pathname-keyed boundary around <Outlet /> as a
safety net (and so the shell survives when Suspense resolves late).
*/}
<Route path="/dashboard" element={routePage(<Dashboard />, "Route:/dashboard")} />
<Route path="/assets/:symbol" element={routePage(<AssetDetail />, "Route:/assets/:symbol")} />
<Route path="/bridges" element={routePage(<Bridges />, "Route:/bridges")} />
<Route path="/incidents" element={routePage(<Incidents />, "Route:/incidents")} />
<Route path="/incidents/replay/:id" element={routePage(<IncidentReplay />, "Route:/incidents/replay/:id")} />
<Route path="/alerts" element={routePage(<Alerts />, "Route:/alerts")} />
<Route path="/alert-playbooks" element={routePage(<AlertPlaybookViewer />, "Route:/alert-playbooks")} />
<Route path="/transactions" element={routePage(<Transactions />, "Route:/transactions")} />
<Route path="/analytics" element={routePage(<Analytics />, "Route:/analytics")} />
<Route path="/analytics/metric-builder" element={routePage(<CustomMetricBuilder />, "Route:/analytics/metric-builder")} />
<Route path="/reports" element={routePage(<Reports />, "Route:/reports")} />
<Route path="/watchlist" element={routePage(<WatchlistPage />, "Route:/watchlist")} />
<Route path="/watchlists" element={routePage(<WatchlistsPage />, "Route:/watchlists")} />
<Route path="/settings" element={routePage(<Settings />, "Route:/settings")} />
<Route path="/admin/api-keys" element={routePage(<ApiKeys />, "Route:/admin/api-keys")} />
<Route path="/admin/alert-routing" element={routePage(<AlertRoutingAdmin />, "Route:/admin/alert-routing")} />
<Route path="/admin/access-audit" element={routePage(<OperationalAccessAudit />, "Route:/admin/access-audit")} />
<Route path="/supply-chain" element={routePage(<SupplyChain />, "Route:/supply-chain")} />
<Route path="/bridge-topology" element={routePage(<BridgeTopologyExplorer />, "Route:/bridge-topology")} />
<Route path="/reconciliation" element={routePage(<Reconciliation />, "Route:/reconciliation")} />
<Route path="/api-docs" element={routePage(<ApiDocs />, "Route:/api-docs")} />
<Route path="/help" element={routePage(<Help />, "Route:/help")} />
<Route path="/release-notes" element={routePage(<ReleaseNotes />, "Route:/release-notes")} />
<Route path="/notification-preferences" element={routePage(<NotificationPreferencesPage />, "Route:/notification-preferences")} />
<Route path="/relationship-explorer" element={routePage(<RelationshipExplorer />, "Route:/relationship-explorer")} />
<Route path="/search" element={routePage(<SearchResultsPage />, "Route:/search")} />
<Route path="/data-provenance" element={routePage(<DataProvenanceGraph />, "Route:/data-provenance")} />
<Route path="/alert-sandbox" element={routePage(<AlertSimulationSandbox />, "Route:/alert-sandbox")} />
<Route path="/liquidity-fragmentation" element={routePage(<LiquidityFragmentation />, "Route:/liquidity-fragmentation")} />
<Route path="/liquidity-dashboard" element={routePage(<LiquidityDashboard />, "Route:/liquidity-dashboard")} />
<Route path="/schema-drift" element={routePage(<SchemaDriftMonitor />, "Route:/schema-drift")} />
<Route path="/bridge-health-timeline" element={routePage(<BridgeHealthTimeline />, "Route:/bridge-health-timeline")} />
<Route path="/export-scheduler" element={routePage(<ExportScheduler />, "Route:/export-scheduler")} />
<Route path="/asset-comparison" element={routePage(<AssetComparison />, "Route:/asset-comparison")} />
<Route path="/metrics-sidebar" element={routePage(<MetricsSidebarPage />, "Route:/metrics-sidebar")} />
<Route path="/cross-chain-verification" element={routePage(<CrossChainVerification />, "Route:/cross-chain-verification")} />
<Route path="/freshness" element={routePage(<FreshnessMonitoring />, "Route:/freshness")} />
</Route>
</Routes>
</Suspense>
Expand Down
5 changes: 3 additions & 2 deletions src/components/ErrorBoundary/ErrorFallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("ErrorFallback", () => {
expect(screen.queryByRole("button", { name: /reload page/i })).not.toBeInTheDocument();
});

it("displays error ID when errorInfo is provided", () => {
it("displays a copyable error reference when errorInfo is provided", () => {
render(
<ErrorFallback
{...defaultProps}
Expand All @@ -63,7 +63,8 @@ describe("ErrorFallback", () => {
}}
/>
);
expect(screen.getByText("err-abc123")).toBeInTheDocument();
expect(screen.getByTestId("error-reference-id")).toHaveTextContent("err-abc123");
expect(screen.getByRole("button", { name: /copy error reference/i })).toBeInTheDocument();
});

it("has role=alert for accessibility", () => {
Expand Down
22 changes: 19 additions & 3 deletions src/components/ErrorBoundary/ErrorFallback.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import CopyButton from "../CopyButton";
import type { ErrorFallbackProps } from "./types";

const isDev = import.meta.env.DEV;
Expand Down Expand Up @@ -88,9 +89,24 @@ export default function ErrorFallback({
<p className={`mt-1 text-sm ${styles.text}/80`}>{displayMessage}</p>

{errorInfo?.id && (
<p className="mt-2 text-xs text-stellar-text-secondary">
Error ID: <code className="font-mono">{errorInfo.id}</code>
</p>
<div
className="mt-3 flex flex-wrap items-center justify-center gap-2 text-xs text-stellar-text-secondary"
data-testid="error-reference"
>
<span>
Error reference:{" "}
<code className="font-mono text-stellar-text-primary" data-testid="error-reference-id">
{errorInfo.id}
</code>
</span>
<CopyButton
value={errorInfo.id}
label="Copy"
copiedLabel="Copied"
variant="inline"
ariaLabel="Copy error reference"
/>
</div>
)}

<div className="mt-4 flex items-center justify-center gap-3">
Expand Down
120 changes: 120 additions & 0 deletions src/components/ErrorBoundary/RouteErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useState } from "react";
import { render, screen, userEvent } from "../../test/utils";
import RouteErrorBoundary from "./RouteErrorBoundary";
import { clearErrorLog, getErrorLog } from "./errorReporting";

const originalConsoleError = console.error;
beforeEach(() => {
clearErrorLog();
console.error = vi.fn();
});
afterEach(() => {
console.error = originalConsoleError;
});

function AlwaysThrow(): JSX.Element {
throw new Error("Route boom");
}

function FlakyRoute({ fail }: { fail: boolean }): JSX.Element {
if (fail) {
throw new Error("Transient route failure");
}
return <div>Route recovered</div>;
}

describe("RouteErrorBoundary", () => {
it("renders children when no error", () => {
render(
<RouteErrorBoundary context="Route:/dashboard">
<div>Dashboard content</div>
</RouteErrorBoundary>
);
expect(screen.getByText("Dashboard content")).toBeInTheDocument();
});

it("catches a render error and shows a friendly route fallback", () => {
render(
<RouteErrorBoundary context="Route:/bridge-topology">
<AlwaysThrow />
</RouteErrorBoundary>
);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("This page crashed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
});

it("logs the error with a reference id via the reporting path", () => {
render(
<RouteErrorBoundary context="Route:/liquidity-dashboard" severity="high">
<AlwaysThrow />
</RouteErrorBoundary>
);
const log = getErrorLog();
expect(log).toHaveLength(1);
expect(log[0].context).toBe("Route:/liquidity-dashboard");
expect(log[0].severity).toBe("high");
expect(log[0].id).toMatch(/^err-/);
expect(screen.getByTestId("error-reference-id")).toHaveTextContent(log[0].id);
});

it("surfaces a copyable error reference", () => {
render(
<RouteErrorBoundary context="Route:/test">
<AlwaysThrow />
</RouteErrorBoundary>
);
expect(screen.getByTestId("error-reference")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /copy error reference/i })).toBeInTheDocument();
});

it("Try again remounts the route and recovers when the error is gone", async () => {
const user = userEvent.setup();

function Harness() {
const [fail, setFail] = useState(true);
return (
<RouteErrorBoundary
context="Route:/retry"
onReset={() => {
setFail(false);
}}
>
<FlakyRoute fail={fail} />
</RouteErrorBoundary>
);
}

render(<Harness />);
expect(screen.getByRole("alert")).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: /try again/i }));
expect(screen.getByText("Route recovered")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("calls onError when a route crashes", () => {
const onError = vi.fn();
render(
<RouteErrorBoundary context="Route:/x" onError={onError}>
<AlwaysThrow />
</RouteErrorBoundary>
);
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
});

it("isolates errors so siblings outside the boundary still render", () => {
render(
<div>
<nav>App shell nav</nav>
<RouteErrorBoundary context="Route:/crashed">
<AlwaysThrow />
</RouteErrorBoundary>
</div>
);
expect(screen.getByText("App shell nav")).toBeInTheDocument();
expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
101 changes: 101 additions & 0 deletions src/components/ErrorBoundary/RouteErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from "react";
import ErrorFallback from "./ErrorFallback";
import { logError } from "./errorReporting";
import type { ErrorInfo, RouteErrorBoundaryProps } from "./types";

const isDev = import.meta.env.DEV;

interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
/** Incremented on retry so the route tree fully remounts. */
retryKey: number;
}

/**
* Route-level error boundary: isolates render failures to a single page,
* offers remount-based retry, and surfaces a copyable error reference id.
*
* In development, errors are still reported loudly (console + stack in the
* fallback) so they are not silently swallowed while keeping a recovery path.
*/
export default class RouteErrorBoundary extends React.Component<
RouteErrorBoundaryProps,
State
> {
constructor(props: RouteErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null, retryKey: 0 };
this.resetError = this.resetError.bind(this);
}

static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}

componentDidCatch(error: Error, reactErrorInfo: React.ErrorInfo) {
const entry = logError(
error,
reactErrorInfo.componentStack ?? undefined,
this.props.severity ?? "high",
this.props.context ?? "Route"
);
this.setState({ errorInfo: entry });
this.props.onError?.(error, reactErrorInfo);

// Do not swallow in development: surface full diagnostics for the overlay/console.
if (isDev) {
console.error(
`[RouteErrorBoundary] Render error (ref: ${entry.id})`,
error,
reactErrorInfo.componentStack
);
}
}

resetError() {
this.setState((prev) => ({
hasError: false,
error: null,
errorInfo: null,
retryKey: prev.retryKey + 1,
}));
this.props.onReset?.();
}

render() {
if (this.state.hasError && this.state.error) {
const { fallback, severity = "high" } = this.props;

if (typeof fallback === "function") {
return fallback({
error: this.state.error,
errorInfo: this.state.errorInfo ?? undefined,
resetError: this.resetError,
severity,
});
}

if (fallback) {
return fallback;
}

return (
<ErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo ?? undefined}
resetError={this.resetError}
severity={severity}
title="This page crashed"
message="Something went wrong while loading this page. You can try again without leaving the rest of the app."
/>
);
}

// Key forces a full remount of the route tree on "Try again".
return (
<React.Fragment key={this.state.retryKey}>{this.props.children}</React.Fragment>
);
}
}
6 changes: 6 additions & 0 deletions src/components/ErrorBoundary/errorReporting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ describe("logError", () => {
const entry2 = logError(new Error("b"));
expect(entry1.id).not.toBe(entry2.id);
});

it("includes the reference id in the returned entry for support correlation", () => {
const entry = logError(new Error("support case"), undefined, "high", "Route:/x");
expect(entry.id).toMatch(/^err-\d+-[a-z0-9]+$/);
expect(entry.context).toBe("Route:/x");
});
});

describe("getErrorLog", () => {
Expand Down
Loading
Loading