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 `