From da0d1ac35c6a57d8b6a3a6994c5cf3892a67ef7c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 00:57:05 +0200 Subject: [PATCH] fix(ui): catch render throws with an error boundary around routed content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A purchase in a currency with no configured exchange rate makes src/domain/fx.ts throw MissingRateError (correct behavior — never silently convert at a wrong rate). Nothing caught it: 5 of 6 routes call monthView/yearView/summaryView and threw during render, and with no error boundary anywhere in src/, the throw unmounted the entire React tree including the nav bar. Since the catch-all route redirects to the current month on first load, one un-rated foreign-currency purchase white-screened the app immediately on landing, with the only way out (Settings, where the rate is added) unreachable because the nav was gone too. Adds RouteErrorBoundary (a class component, as required for getDerivedStateFromError/componentDidCatch) that renders the thrown message plus a link to Settings and a "Try again" reset, and RouteErrorBoundaryReset, a wrapper that keys the boundary on the current route so it clears on navigation. Wraps only in App.tsx, not
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
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 `