diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index cd9cbbe..920e97c 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -8,6 +8,7 @@ import { PostYearRoute } from "./routes/PostYearRoute.tsx";
import { SummaryRoute } from "./routes/SummaryRoute.tsx";
import { SettingsRoute } from "./routes/SettingsRoute.tsx";
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
+import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx";
export function AppRoutes() {
const year = currentMonth.slice(0, 4);
@@ -28,15 +29,17 @@ export function AppRoutes() {
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
diff --git a/src/ui/components/ErrorBoundary.test.tsx b/src/ui/components/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..cf95646
--- /dev/null
+++ b/src/ui/components/ErrorBoundary.test.tsx
@@ -0,0 +1,112 @@
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+// Bun can run multiple test files in one process, and registerSw.test.ts
+// registers happy-dom globally too — guard so both files can run together.
+if (!GlobalRegistrator.isRegistered) {
+ GlobalRegistrator.register();
+}
+
+// Silences React's "environment not configured to support act(...)" warning;
+// this file drives React purely through explicit `act()` calls below.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+import { test, expect, afterEach } from "bun:test";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { Link, MemoryRouter, useLocation } from "react-router";
+import { RouteErrorBoundary, RouteErrorBoundaryReset } from "./ErrorBoundary.tsx";
+
+let container: HTMLDivElement | null = null;
+let root: Root | null = null;
+
+function mount(children: React.ReactNode) {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => {
+ root!.render(children);
+ });
+ return container;
+}
+
+afterEach(() => {
+ if (root) {
+ act(() => {
+ root!.unmount();
+ });
+ root = null;
+ }
+ if (container) {
+ container.remove();
+ container = null;
+ }
+});
+
+function Thrower(): never {
+ throw new Error("No exchange rate configured for EUR");
+}
+
+function Fine() {
+ return
all good
;
+}
+
+test("a throwing child renders the fallback including the thrown message", () => {
+ const el = mount(
+
+
+
+
+ ,
+ );
+
+ expect(el.textContent).toContain("This view could not be displayed");
+ expect(el.textContent).toContain("No exchange rate configured for EUR");
+ // The way back to Settings must still be present.
+ const link = el.querySelector("a[href*='/settings']");
+ expect(link).not.toBeNull();
+ // "Try again" control is present to retry without a full reload.
+ expect(el.textContent).toContain("Try again");
+});
+
+test("a non-throwing child renders normally", () => {
+ const el = mount(
+
+
+
+
+ ,
+ );
+
+ expect(el.textContent).toContain("all good");
+ expect(el.textContent).not.toContain("could not be displayed");
+});
+
+function View() {
+ const location = useLocation();
+ return (
+
+ {location.pathname === "/a" ? : }
+
+ );
+}
+
+test("RouteErrorBoundaryReset resets the error state when the route changes", () => {
+ const el = mount(
+
+ go to settings
+
+ ,
+ );
+ expect(el.textContent).toContain("No exchange rate configured for EUR");
+
+ // A real navigation (e.g. following the boundary's Settings link) changes
+ // the route; the boundary is keyed on location.pathname, so it should
+ // remount with a clean error state instead of staying stuck.
+ const link = el.querySelector("a");
+ expect(link).not.toBeNull();
+ act(() => {
+ link!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
+ });
+
+ expect(el.textContent).toContain("all good");
+ expect(el.textContent).not.toContain("could not be displayed");
+});
diff --git a/src/ui/components/ErrorBoundary.tsx b/src/ui/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..27ca4a0
--- /dev/null
+++ b/src/ui/components/ErrorBoundary.tsx
@@ -0,0 +1,82 @@
+import { Component } from "react";
+import type { ErrorInfo, ReactNode } from "react";
+import { Link, useLocation } from "react-router";
+import { Button } from "@/components/ui/button";
+
+interface RouteErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface RouteErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Catches render-time throws from routed views — most notably
+ * `MissingRateError` from `src/domain/fx.ts`, which is thrown deliberately
+ * whenever a purchase's currency has no configured exchange rate. Silently
+ * converting at a fallback rate would show the user wrong numbers, so the
+ * domain throws instead; this boundary is what turns that throw into a
+ * recoverable screen instead of an unmounted app.
+ *
+ * Must be positioned in `App.tsx` so it wraps only the routed content, not
+ * the `