From 227f74707e1cec6034b0c0ae0014bbbdcd91c2ef Mon Sep 17 00:00:00 2001 From: Jenyfer Date: Thu, 23 Jul 2026 18:35:13 -0600 Subject: [PATCH 1/3] feat(frontend): implement react error boundaries (#188) --- CHANGELOG.md | 1 + frontend/app/api/errors/route.test.ts | 153 +++++++++++++ frontend/app/api/errors/route.ts | 112 ++++++++++ frontend/app/dashboard/create/[type]/page.tsx | 5 +- frontend/app/dashboard/group/[id]/page.tsx | 88 +++++--- frontend/app/dashboard/notifications/page.tsx | 5 +- frontend/app/dashboard/page.tsx | 21 +- frontend/app/explore/page.tsx | 7 +- frontend/components/error-boundary.test.ts | 202 +++++++++++++++++ frontend/components/error-boundary.tsx | 209 ++++++++++++++++++ frontend/lib/error-reporting.test.ts | 120 ++++++++++ frontend/lib/error-reporting.ts | 100 +++++++++ frontend/package.json | 2 +- 13 files changed, 976 insertions(+), 49 deletions(-) create mode 100644 frontend/app/api/errors/route.test.ts create mode 100644 frontend/app/api/errors/route.ts create mode 100644 frontend/components/error-boundary.test.ts create mode 100644 frontend/components/error-boundary.tsx create mode 100644 frontend/lib/error-reporting.test.ts create mode 100644 frontend/lib/error-reporting.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c8264..aeaf175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to **JointSave** will be documented in this file. ### Added +- **React Error Boundaries** with fallback UIs and error reporting across all dashboard routes to prevent full page crashes. - **Smart contract pause & recovery** capabilities to improve safety and operational resilience of deployed pool contracts. - **Multi-token support** (including support for the native token) for pool participation. - **Reputation tracking** to represent participant trust based on pool participation. diff --git a/frontend/app/api/errors/route.test.ts b/frontend/app/api/errors/route.test.ts new file mode 100644 index 0000000..4329afe --- /dev/null +++ b/frontend/app/api/errors/route.test.ts @@ -0,0 +1,153 @@ +/** + * Unit tests for POST /api/errors route handler. + * + * Uses Node.js built-in test runner (node:test + node:assert) to match + * existing project test conventions. + */ +import { test, mock, beforeEach, before } from "node:test" +import assert from "node:assert" + +// Set env vars so Supabase client initializes without throwing +process.env.NEXT_PUBLIC_SUPABASE_URL = "https://mock.supabase.co" +process.env.SUPABASE_SERVICE_ROLE_KEY = "mock-key" + +let fetchCalls: Array<{ url: string; init: RequestInit }> = [] +let shouldInsertFail = false + +let originalFetch: typeof globalThis.fetch +let POST: any + +before(async () => { + originalFetch = globalThis.fetch + + // Import the route handler AFTER setting env vars + const route = await import("./route") + POST = route.POST +}) + +beforeEach(() => { + fetchCalls = [] + shouldInsertFail = false + + globalThis.fetch = mock.fn(async (url: string | URL | Request, init?: RequestInit) => { + fetchCalls.push({ url: String(url), init: init! }) + + if (shouldInsertFail) { + return new Response(JSON.stringify({ error: { message: "DB insert failed" } }), { + status: 400, + headers: { "Content-Type": "application/json" } + }) + } + + return new Response(JSON.stringify([{ id: "mock-id" }]), { + status: 201, + headers: { "Content-Type": "application/json" } + }) + }) as typeof globalThis.fetch +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createRequest(body: unknown, headers?: Record): Request { + return new Request("http://localhost:3000/api/errors", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + + +test("POST /api/errors — returns 400 when message is missing", async () => { + const req = createRequest({ stack: "some stack" }) + // Cast to NextRequest-compatible since the route expects NextRequest + const res = await POST(req as unknown as Parameters[0]) + assert.strictEqual(res.status, 400) + const json = await res.json() + assert.strictEqual(json.error, "VALIDATION_ERROR") +}) + +test("POST /api/errors — returns 400 for empty message", async () => { + const req = createRequest({ message: "" }) + const res = await POST(req as unknown as Parameters[0]) + assert.strictEqual(res.status, 400) +}) + +test("POST /api/errors — returns 201 on valid payload", async () => { + const req = createRequest({ + message: "Test component crashed", + stack: "Error: Test\n at Component", + walletAddress: "GABCDEF123", + sectionName: "Dashboard", + url: "http://localhost:3000/dashboard", + }) + const res = await POST(req as unknown as Parameters[0]) + assert.strictEqual(res.status, 201) + + const json = await res.json() + assert.strictEqual(json.ok, true) + + // Verify Supabase insert was called via fetch + assert.strictEqual(fetchCalls.length, 1) + const inserted = JSON.parse(fetchCalls[0].init.body as string) + assert.strictEqual(inserted.job_name, "client_error") + assert.strictEqual(inserted.status, "failed") + assert.ok( + (inserted.error_message as string).includes("Test component crashed"), + "error_message should contain the original message", + ) +}) + +test("POST /api/errors — includes section and wallet info in error_message", async () => { + const req = createRequest({ + message: "RPC call failed", + walletAddress: "GXYZ789", + sectionName: "Yield Dashboard", + }) + const res = await POST(req as unknown as Parameters[0]) + assert.strictEqual(res.status, 201) + + const inserted = JSON.parse(fetchCalls[0].init.body as string) + const msg = inserted.error_message as string + assert.ok(msg.includes("Section: Yield Dashboard")) + assert.ok(msg.includes("Wallet: GXYZ789")) +}) + +test("POST /api/errors — returns 500 when Supabase insert fails", async () => { + shouldInsertFail = true + const req = createRequest({ message: "Some error" }) + const res = await POST(req as unknown as Parameters[0]) + assert.strictEqual(res.status, 500) + const json = await res.json() + assert.strictEqual(json.error, "DB_ERROR") +}) + +test("POST /api/errors — rate-limits after 5 requests from same IP", async () => { + // Send 5 requests (should all succeed) + for (let i = 0; i < 5; i++) { + const req = createRequest( + { message: `Error ${i}` }, + { "x-forwarded-for": "192.168.1.100" }, + ) + const res = await POST(req as unknown as Parameters[0]) + assert.strictEqual(res.status, 201, `Request ${i + 1} should succeed`) + } + + // 6th request should be rate-limited + const req = createRequest( + { message: "Error 6 — should be blocked" }, + { "x-forwarded-for": "192.168.1.100" }, + ) + const res = await POST(req as unknown as Parameters[0]) + assert.strictEqual(res.status, 429) + const json = await res.json() + assert.strictEqual(json.error, "TOO_MANY_REQUESTS") +}) diff --git a/frontend/app/api/errors/route.ts b/frontend/app/api/errors/route.ts new file mode 100644 index 0000000..a8b3756 --- /dev/null +++ b/frontend/app/api/errors/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" + +// --------------------------------------------------------------------------- +// Server-side rate limiter (sliding window, in-memory) +// --------------------------------------------------------------------------- + +const MAX_REPORTS_PER_MINUTE = 5 +const WINDOW_MS = 60_000 + +const rateLimitStore = new Map() + +function isServerRateLimited(key: string): boolean { + const now = Date.now() + let timestamps = rateLimitStore.get(key) + if (!timestamps) { + timestamps = [] + rateLimitStore.set(key, timestamps) + } + // Prune old entries + while (timestamps.length > 0 && timestamps[0] <= now - WINDOW_MS) { + timestamps.shift() + } + if (timestamps.length >= MAX_REPORTS_PER_MINUTE) { + return true + } + timestamps.push(now) + return false +} + +function resolveRateLimitKey(req: NextRequest, walletAddress?: string): string { + if (walletAddress) return `err:wallet:${walletAddress.toLowerCase()}` + const forwarded = req.headers.get("x-forwarded-for") + const ip = forwarded ? forwarded.split(",")[0].trim() : "unknown" + return `err:ip:${ip}` +} + +// --------------------------------------------------------------------------- +// POST /api/errors +// --------------------------------------------------------------------------- + +/** + * Receives client-side error reports from React Error Boundaries and logs + * them to the `cron_job_logs` Supabase table with `job_name: 'client_error'`. + * + * Rate-limited to 5 reports per minute per wallet/IP. + */ +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const { message, stack, componentStack, walletAddress, sectionName, url } = body as { + message?: string + stack?: string + componentStack?: string + walletAddress?: string + sectionName?: string + url?: string + } + + if (!message || typeof message !== "string") { + return NextResponse.json( + { error: "VALIDATION_ERROR", message: "Missing or invalid 'message' field." }, + { status: 400 }, + ) + } + + // Rate limit + const key = resolveRateLimitKey(req, walletAddress) + if (isServerRateLimited(key)) { + return NextResponse.json( + { error: "TOO_MANY_REQUESTS", message: "Error reporting rate limit exceeded." }, + { status: 429 }, + ) + } + + // Build the error_message payload stored in cron_job_logs + const errorDetail = [ + `[client_error] ${message}`, + sectionName ? `Section: ${sectionName}` : null, + walletAddress ? `Wallet: ${walletAddress}` : null, + url ? `URL: ${url}` : null, + stack ? `\nStack:\n${stack}` : null, + componentStack ? `\nComponent Stack:\n${componentStack}` : null, + ] + .filter(Boolean) + .join("\n") + + // Insert into cron_job_logs using the admin (service-role) client so + // RLS doesn't block the write. + const supabase = getAdminClient() + const { error: dbError } = await supabase.from("cron_job_logs").insert({ + job_name: "client_error", + status: "failed", + error_message: errorDetail.slice(0, 4096), // cap to prevent oversized payloads + } as any) + + if (dbError) { + console.error("[POST /api/errors] Supabase insert failed:", dbError) + return NextResponse.json( + { error: "DB_ERROR", message: "Failed to log error." }, + { status: 500 }, + ) + } + + return NextResponse.json({ ok: true }, { status: 201 }) + } catch { + return NextResponse.json( + { error: "INTERNAL_ERROR", message: "Unexpected server error." }, + { status: 500 }, + ) + } +} diff --git a/frontend/app/dashboard/create/[type]/page.tsx b/frontend/app/dashboard/create/[type]/page.tsx index 2fa9a60..9568f6b 100644 --- a/frontend/app/dashboard/create/[type]/page.tsx +++ b/frontend/app/dashboard/create/[type]/page.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button" import { ArrowLeft } from "lucide-react" import Link from "next/link" import { redirect } from "next/navigation" +import { ErrorBoundary } from "@/components/error-boundary" const validTypes = ["rotational", "target", "flexible"] @@ -63,7 +64,8 @@ export default function CreateGroupPage({ params }: { params: Promise<{ type: st } return ( -
+ +
@@ -91,5 +93,6 @@ export default function CreateGroupPage({ params }: { params: Promise<{ type: st
+
) } diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index 6ee770b..8815472 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -15,6 +15,7 @@ import Link from "next/link" import { fetchIsPaused, fetchPoolAdmin } from "@/hooks/useJointSaveContracts" import { useStellar } from "@/components/web3-provider" import { useRecentPools } from "@/hooks/useRecentPools" +import { ErrorBoundary, SectionErrorBoundary } from "@/components/error-boundary" interface Pool { id: string @@ -88,42 +89,61 @@ export default function GroupPage({ params }: { params: Promise<{ id: string }> : pool.id return ( -
- -
- + +
+ +
+ -
-
- - - {/* Admin audit log with CSV export — only shown to the pool creator */} - - {/* Admin actions log — visible to all pool members */} - -
+
+
+ + + + + + + {/* Admin audit log with CSV export — only shown to the pool creator */} + + + + {/* Admin actions log — visible to all pool members */} + + + +
-
- - {pool.type === "flexible" && } - +
+ + + + {pool.type === "flexible" && ( + + + + )} + + + +
-
-
-
+
+
+ ) } + diff --git a/frontend/app/dashboard/notifications/page.tsx b/frontend/app/dashboard/notifications/page.tsx index 5c34359..cb36586 100644 --- a/frontend/app/dashboard/notifications/page.tsx +++ b/frontend/app/dashboard/notifications/page.tsx @@ -17,6 +17,7 @@ import { DashboardHeader } from "@/components/dashboard/dashboard-header" import { useStellar } from "@/components/web3-provider" import { ArrowLeft, Bell } from "lucide-react" import { formatRelativeTime } from "@/lib/utils" +import { ErrorBoundary } from "@/components/error-boundary" import type { AppNotification } from "@/hooks/useNotifications" const PAGE_SIZE = 10 @@ -74,7 +75,7 @@ export default function NotificationsPage() { }, [page, loadNotifications]) return ( -
+
-
+ ) } diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index d68d0db..43b7c8d 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -8,9 +8,10 @@ import { DashboardTabs } from "@/components/dashboard/dashboard-tabs" import { BackToTop } from "@/components/ui/back-to-top" import { KeyboardShortcutsHelp } from "@/components/dashboard/keyboard-shortcuts-help" import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts" +import { ErrorBoundary } from "@/components/error-boundary" export default function DashboardPage() { - const { isConnected, isInitializing } = useStellar() + const { isConnected, isInitializing, address } = useStellar() const [activeTab, setActiveTab] = useState("groups") const [showHelp, setShowHelp] = useState(false) @@ -37,13 +38,15 @@ export default function DashboardPage() { } return ( -
- -
- -
- - -
+ +
+ +
+ +
+ + +
+
) } diff --git a/frontend/app/explore/page.tsx b/frontend/app/explore/page.tsx index 7e5bbd6..82c4baf 100644 --- a/frontend/app/explore/page.tsx +++ b/frontend/app/explore/page.tsx @@ -30,6 +30,7 @@ import { fetchFactoryPools } from "@/hooks/useJointSaveContracts" import Link from "next/link" import { useToast } from "@/hooks/use-toast" import { useDebouncedValue } from "@/hooks/use-debounced-value" +import { ErrorBoundary } from "@/components/error-boundary" // ── Types ───────────────────────────────────────────────────────────────────── @@ -416,7 +417,7 @@ function ExploreContent() { toast({ title: "Error", description: err instanceof Error ? err.message : "Something went wrong", - variant: "destructive", + variant: "error", }) } finally { setJoining(null) @@ -561,7 +562,9 @@ function ExploreFallback() { export default function ExplorePage() { return ( }> - + + + ) } diff --git a/frontend/components/error-boundary.test.ts b/frontend/components/error-boundary.test.ts new file mode 100644 index 0000000..3faeb7a --- /dev/null +++ b/frontend/components/error-boundary.test.ts @@ -0,0 +1,202 @@ +/** + * Unit tests for the ErrorBoundary class component. + * + * Tests class-level logic directly — lifecycle methods, state transitions, and + * error-reporter integration — without a DOM renderer. Follows the project's + * existing test conventions: node:test + node:assert, run via `tsx --test`. + * + * Strategy + * -------- + * We mock globalThis.fetch (the same technique used in route.test.ts and + * error-reporting.test.ts) to intercept the reportClientError call that + * ErrorBoundary fires inside componentDidCatch. We then dynamically import + * the component inside `before()` so the mock is in place before any module + * code runs. + * + * To make setState testable without a mounted React tree we override it on + * each instance to be synchronous, allowing immediate state inspection. + */ + +import { test, describe, before, beforeEach, mock } from "node:test" +import assert from "node:assert" + +// --------------------------------------------------------------------------- +// Types (declared before before() so tests can reference them) +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let ErrorBoundary: any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let SectionErrorBoundary: any + +let fetchCalls: Array<{ url: string; body: Record }> = [] + +// --------------------------------------------------------------------------- +// Setup — runs once before all tests +// --------------------------------------------------------------------------- + +before(async () => { + // Intercept outbound fetch so reportClientError never hits the network. + // This mirrors the technique in lib/error-reporting.test.ts. + globalThis.fetch = mock.fn(async (url: string | URL | Request, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) : {} + fetchCalls.push({ url: String(url), body }) + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof globalThis.fetch + + // Reset the client-side rate limiter before each test so prior fetch mocks + // (from error-reporting.test.ts running in the same tsx process) don't bleed in. + const { _resetRateLimiter } = await import("../lib/error-reporting") + _resetRateLimiter() + + // Dynamic import ensures our fetch mock is already in place when the module + // (and its transitive imports) first execute. + const mod = await import("./error-boundary") + ErrorBoundary = mod.ErrorBoundary + SectionErrorBoundary = mod.SectionErrorBoundary +}) + +beforeEach(() => { + fetchCalls = [] + // Reset the rate limiter so each test starts with a clean slate. + // We re-import synchronously because the module is already cached. + import("../lib/error-reporting").then(({ _resetRateLimiter }) => _resetRateLimiter()) +}) + +// --------------------------------------------------------------------------- +// Helper — create an ErrorBoundary instance with a synchronous setState +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeInstance(props: Record = {}): any { + const instance = new ErrorBoundary({ children: null, ...props }) + // React.Component.setState is async when mounted; here the component is + // never mounted, so we replace it with a synchronous version for testing. + instance.setState = function ( + update: ((prev: Record) => Record) | Record + ) { + if (typeof update === "function") { + this.state = { ...this.state, ...update(this.state) } + } else { + this.state = { ...this.state, ...update } + } + } + return instance +} + +// --------------------------------------------------------------------------- +// Tests: ErrorBoundary +// --------------------------------------------------------------------------- + +describe("ErrorBoundary", () => { + // ── Static lifecycle ─────────────────────────────────────────────────────── + + test("getDerivedStateFromError — sets hasError: true and stores the error", () => { + const error = new Error("render boom") + const state = ErrorBoundary.getDerivedStateFromError(error) + assert.strictEqual(state.hasError, true) + assert.strictEqual(state.error, error) + }) + + // ── Constructor / initial state ──────────────────────────────────────────── + + test("constructor — initialises with hasError: false, error: null, showDetails: false", () => { + const instance = makeInstance() + assert.strictEqual(instance.state.hasError, false) + assert.strictEqual(instance.state.error, null) + assert.strictEqual(instance.state.showDetails, false) + }) + + // ── componentDidCatch ────────────────────────────────────────────────────── + + test("componentDidCatch — forwards error details to reportClientError", async () => { + const instance = makeInstance({ sectionName: "Dashboard", walletAddress: "GABC123" }) + const error = new Error("test crash") + error.stack = "Error: test crash\n at Dashboard" + + instance.componentDidCatch(error, { componentStack: "\n at Dashboard" }) + + // reportClientError is fire-and-forget; drain the microtask queue. + await new Promise((resolve) => setImmediate(resolve)) + + assert.strictEqual(fetchCalls.length, 1, "fetch should be called once") + const { url, body } = fetchCalls[0] + assert.strictEqual(url, "/api/errors") + assert.strictEqual(body.message, "test crash") + assert.strictEqual(body.stack, error.stack) + assert.strictEqual(body.componentStack, "\n at Dashboard") + assert.strictEqual(body.walletAddress, "GABC123") + assert.strictEqual(body.sectionName, "Dashboard") + }) + + test("componentDidCatch — works when optional props are omitted", async () => { + const instance = makeInstance() // no sectionName, no walletAddress + const error = new Error("anonymous error") + + instance.componentDidCatch(error, { componentStack: null }) + + await new Promise((resolve) => setImmediate(resolve)) + + assert.strictEqual(fetchCalls.length, 1) + const { body } = fetchCalls[0] + assert.strictEqual(body.message, "anonymous error") + // componentStack: null → coerced to undefined inside the component + assert.strictEqual(body.componentStack, undefined) + assert.strictEqual(body.walletAddress, undefined) + assert.strictEqual(body.sectionName, undefined) + }) + + // ── handleReset ──────────────────────────────────────────────────────────── + + test("handleReset — resets state to initial values", () => { + const instance = makeInstance() + instance.state = { hasError: true, error: new Error("x"), showDetails: true } + + instance.handleReset() + + assert.strictEqual(instance.state.hasError, false) + assert.strictEqual(instance.state.error, null) + assert.strictEqual(instance.state.showDetails, false) + }) + + test("handleReset — invokes the onReset callback when provided", () => { + const onReset = mock.fn() + const instance = makeInstance({ onReset }) + instance.state = { hasError: true, error: new Error("x"), showDetails: false } + + instance.handleReset() + + assert.strictEqual(onReset.mock.calls.length, 1) + }) + + test("handleReset — does not throw when onReset is not provided", () => { + const instance = makeInstance() + assert.doesNotThrow(() => instance.handleReset()) + }) + + // ── toggleDetails ────────────────────────────────────────────────────────── + + test("toggleDetails — flips showDetails false → true → false", () => { + const instance = makeInstance() + assert.strictEqual(instance.state.showDetails, false) + + instance.toggleDetails() + assert.strictEqual(instance.state.showDetails, true) + + instance.toggleDetails() + assert.strictEqual(instance.state.showDetails, false) + }) +}) + +// --------------------------------------------------------------------------- +// Tests: SectionErrorBoundary +// --------------------------------------------------------------------------- + +describe("SectionErrorBoundary", () => { + test("is exported as a function (functional component wrapper)", () => { + assert.strictEqual(typeof SectionErrorBoundary, "function") + }) +}) diff --git a/frontend/components/error-boundary.tsx b/frontend/components/error-boundary.tsx new file mode 100644 index 0000000..c9b7513 --- /dev/null +++ b/frontend/components/error-boundary.tsx @@ -0,0 +1,209 @@ +"use client" + +import React, { Component, type ErrorInfo, type ReactNode } from "react" +import Link from "next/link" +import { AlertTriangle, ChevronDown, ChevronUp, Home, RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { reportClientError } from "@/lib/error-reporting" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ErrorBoundaryProps { + children: ReactNode + /** Human-readable name shown in fallback UI (e.g. "Dashboard", "Members List"). */ + sectionName?: string + /** When true, renders a compact inline fallback suited for inner sections. */ + compact?: boolean + /** Optional callback fired when the user clicks "Try Again". */ + onReset?: () => void + /** Connected wallet address — forwarded to the error reporter. */ + walletAddress?: string | null +} + +interface ErrorBoundaryState { + hasError: boolean + error: Error | null + showDetails: boolean +} + +// --------------------------------------------------------------------------- +// ErrorBoundary (class component — required by React's componentDidCatch API) +// --------------------------------------------------------------------------- + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = { hasError: false, error: null, showDetails: false } + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Log to console for developer visibility + console.error( + `[ErrorBoundary${this.props.sectionName ? ` – ${this.props.sectionName}` : ""}]`, + error, + errorInfo, + ) + + // Report to backend (fire-and-forget) + reportClientError({ + message: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack ?? undefined, + walletAddress: this.props.walletAddress ?? undefined, + sectionName: this.props.sectionName, + }) + } + + private handleReset = (): void => { + this.setState({ hasError: false, error: null, showDetails: false }) + this.props.onReset?.() + } + + private toggleDetails = (): void => { + this.setState((prev) => ({ showDetails: !prev.showDetails })) + } + + render(): ReactNode { + if (!this.state.hasError) { + return this.props.children + } + + const { sectionName, compact } = this.props + const { error, showDetails } = this.state + const label = sectionName ?? "this section" + + // ── Compact fallback (inner section boundaries) ───────────────────── + if (compact) { + return ( + +
+ +
+

+ Failed to load {label} +

+

+ An unexpected error occurred in this section. +

+ + {/* Expandable error details */} + + + {showDetails && error && ( +
+                  {error.message}
+                  {error.stack && `\n\n${error.stack}`}
+                
+ )} + +
+ +
+
+
+
+ ) + } + + // ── Full-page fallback (page-level boundaries) ────────────────────── + return ( +
+ + + +

Something went wrong

+

+ An unexpected error occurred while loading {label}. You can try again or return to the + dashboard. +

+ + {/* Expandable error details */} + + + {showDetails && error && ( +
+              {error.message}
+              {error.stack && `\n\n${error.stack}`}
+            
+ )} + +
+ + +
+
+
+ ) + } +} + +// --------------------------------------------------------------------------- +// SectionErrorBoundary — convenience wrapper for inner section boundaries +// --------------------------------------------------------------------------- + +interface SectionErrorBoundaryProps { + children: ReactNode + sectionName: string + walletAddress?: string | null + onReset?: () => void +} + +/** + * Convenience wrapper that renders a compact error fallback. + * Use this inside page layouts to isolate individual sections. + */ +export function SectionErrorBoundary({ + children, + sectionName, + walletAddress, + onReset, +}: SectionErrorBoundaryProps) { + return ( + + {children} + + ) +} diff --git a/frontend/lib/error-reporting.test.ts b/frontend/lib/error-reporting.test.ts new file mode 100644 index 0000000..bbc48af --- /dev/null +++ b/frontend/lib/error-reporting.test.ts @@ -0,0 +1,120 @@ +/** + * Unit tests for the client-side error reporting utility. + * + * Uses Node.js built-in test runner (node:test + node:assert) to match the + * existing test conventions in this project. + */ +import { test, beforeEach, mock } from "node:test" +import assert from "node:assert" +import { isRateLimited, _resetRateLimiter } from "./error-reporting" + +// --------------------------------------------------------------------------- +// Rate limiter tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + _resetRateLimiter() +}) + +test("isRateLimited — allows up to 5 reports within the window", () => { + for (let i = 0; i < 5; i++) { + assert.strictEqual(isRateLimited(), false, `Report ${i + 1} should be allowed`) + } +}) + +test("isRateLimited — blocks the 6th report within the window", () => { + for (let i = 0; i < 5; i++) { + isRateLimited() + } + assert.strictEqual(isRateLimited(), true, "6th report should be rate-limited") +}) + +test("isRateLimited — resets after _resetRateLimiter is called", () => { + for (let i = 0; i < 5; i++) { + isRateLimited() + } + assert.strictEqual(isRateLimited(), true) + _resetRateLimiter() + assert.strictEqual(isRateLimited(), false, "Should allow reports after reset") +}) + +// --------------------------------------------------------------------------- +// reportClientError tests (using fetch mock) +// --------------------------------------------------------------------------- + +test("reportClientError — sends correct payload to /api/errors", async () => { + _resetRateLimiter() + + const fetchCalls: { url: string; init: RequestInit }[] = [] + + // Mock global fetch + const originalFetch = globalThis.fetch + globalThis.fetch = mock.fn(async (url: string | URL | Request, init?: RequestInit) => { + fetchCalls.push({ url: String(url), init: init! }) + return new Response(JSON.stringify({ ok: true }), { status: 201 }) + }) as typeof globalThis.fetch + + try { + // Dynamic import to pick up the mocked fetch + // Since the module is already loaded, we call reportClientError directly + const { reportClientError } = await import("./error-reporting") + const result = await reportClientError({ + message: "Test error", + stack: "Error: Test error\n at Test", + walletAddress: "GABCD1234", + sectionName: "Dashboard", + }) + + assert.strictEqual(result, true, "Should return true on successful report") + assert.strictEqual(fetchCalls.length, 1) + assert.strictEqual(fetchCalls[0].url, "/api/errors") + + const body = JSON.parse(fetchCalls[0].init.body as string) + assert.strictEqual(body.message, "Test error") + assert.strictEqual(body.stack, "Error: Test error\n at Test") + assert.strictEqual(body.walletAddress, "GABCD1234") + assert.strictEqual(body.sectionName, "Dashboard") + } finally { + globalThis.fetch = originalFetch + } +}) + +test("reportClientError — returns false when rate-limited", async () => { + _resetRateLimiter() + + const originalFetch = globalThis.fetch + globalThis.fetch = mock.fn(async () => { + return new Response(JSON.stringify({ ok: true }), { status: 201 }) + }) as typeof globalThis.fetch + + try { + const { reportClientError } = await import("./error-reporting") + + // Exhaust the rate limit + for (let i = 0; i < 5; i++) { + await reportClientError({ message: `Error ${i}` }) + } + + const result = await reportClientError({ message: "Should be blocked" }) + assert.strictEqual(result, false, "Should return false when rate-limited") + } finally { + globalThis.fetch = originalFetch + } +}) + +test("reportClientError — returns false on network error", async () => { + _resetRateLimiter() + + const originalFetch = globalThis.fetch + globalThis.fetch = mock.fn(async () => { + throw new Error("Network failure") + }) as typeof globalThis.fetch + + try { + const { reportClientError } = await import("./error-reporting") + const result = await reportClientError({ message: "Test error" }) + assert.strictEqual(result, false, "Should return false on network error") + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/frontend/lib/error-reporting.ts b/frontend/lib/error-reporting.ts new file mode 100644 index 0000000..64bd47c --- /dev/null +++ b/frontend/lib/error-reporting.ts @@ -0,0 +1,100 @@ +/** + * Client-side error reporting utility. + * + * Captures runtime errors caught by React Error Boundaries, attaches + * contextual metadata (wallet address, section, component stack), and + * dispatches them to the `/api/errors` server endpoint. + * + * A sliding-window rate limiter (max 5 reports per 60 seconds per + * wallet/client) prevents flooding. Reports are fire-and-forget — + * failures are logged to the console but never re-thrown. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ClientErrorPayload { + message: string + stack?: string + componentStack?: string + walletAddress?: string + sectionName?: string + url?: string +} + +// --------------------------------------------------------------------------- +// Rate limiter (client-side, per-session) +// --------------------------------------------------------------------------- + +const MAX_REPORTS = 5 +const WINDOW_MS = 60_000 // 1 minute + +/** Sliding-window timestamps keyed by wallet (or "anonymous"). */ +const reportTimestamps: number[] = [] + +/** + * Returns `true` if the report should be allowed through, + * `false` if it should be silently dropped. + */ +export function isRateLimited(): boolean { + const now = Date.now() + // Prune timestamps outside the window + while (reportTimestamps.length > 0 && reportTimestamps[0] <= now - WINDOW_MS) { + reportTimestamps.shift() + } + if (reportTimestamps.length >= MAX_REPORTS) { + return true + } + reportTimestamps.push(now) + return false +} + +// --------------------------------------------------------------------------- +// Reporter +// --------------------------------------------------------------------------- + +/** + * Report a client error to the backend. + * + * - Never throws — callers do not need try/catch. + * - Rate-limited: silently drops reports beyond 5 per minute. + * - Runs asynchronously; the returned promise resolves to `true` when the + * report was accepted, `false` when rate-limited or when the request failed. + */ +export async function reportClientError(payload: ClientErrorPayload): Promise { + try { + if (isRateLimited()) { + return false + } + + const body: ClientErrorPayload = { + message: payload.message || "Unknown error", + stack: payload.stack, + componentStack: payload.componentStack, + walletAddress: payload.walletAddress, + sectionName: payload.sectionName, + url: payload.url || (typeof window !== "undefined" ? window.location.href : undefined), + } + + const res = await fetch("/api/errors", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + return res.ok + } catch { + // Network error or other failure — swallow silently. + return false + } +} + +// --------------------------------------------------------------------------- +// Test helpers (exported for unit tests only) +// --------------------------------------------------------------------------- + +/** @internal — reset the rate limiter state between tests. */ +export function _resetRateLimiter(): void { + reportTimestamps.length = 0 +} diff --git a/frontend/package.json b/frontend/package.json index 94456c4..e39308a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "start": "next start", - "test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts", + "test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/error-reporting.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report" From 5e73e3c1174dba4b345546bf980dffe697c84938 Mon Sep 17 00:00:00 2001 From: Jenyfer Date: Fri, 24 Jul 2026 08:02:20 -0600 Subject: [PATCH 2/3] feat(frontend): implement react error boundaries (#188) --- frontend/app/api/errors/route.test.ts | 4 +--- frontend/app/api/errors/route.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/app/api/errors/route.test.ts b/frontend/app/api/errors/route.test.ts index 4329afe..a933e99 100644 --- a/frontend/app/api/errors/route.test.ts +++ b/frontend/app/api/errors/route.test.ts @@ -14,12 +14,10 @@ process.env.SUPABASE_SERVICE_ROLE_KEY = "mock-key" let fetchCalls: Array<{ url: string; init: RequestInit }> = [] let shouldInsertFail = false -let originalFetch: typeof globalThis.fetch +// eslint-disable-next-line @typescript-eslint/no-explicit-any let POST: any before(async () => { - originalFetch = globalThis.fetch - // Import the route handler AFTER setting env vars const route = await import("./route") POST = route.POST diff --git a/frontend/app/api/errors/route.ts b/frontend/app/api/errors/route.ts index a8b3756..e5ce57b 100644 --- a/frontend/app/api/errors/route.ts +++ b/frontend/app/api/errors/route.ts @@ -92,7 +92,7 @@ export async function POST(req: NextRequest) { job_name: "client_error", status: "failed", error_message: errorDetail.slice(0, 4096), // cap to prevent oversized payloads - } as any) + } as never) if (dbError) { console.error("[POST /api/errors] Supabase insert failed:", dbError) From 1aaf4a957a427f64083808dcfb63b2b784c6736e Mon Sep 17 00:00:00 2001 From: Jenyfer Date: Sat, 25 Jul 2026 14:51:43 -0600 Subject: [PATCH 3/3] style with Prettier --- frontend/app/api/errors/route.test.ts | 14 ++---- frontend/app/api/errors/route.ts | 8 +-- frontend/app/dashboard/create/[type]/page.tsx | 50 +++++++++---------- frontend/app/dashboard/group/[id]/page.tsx | 1 - frontend/components/error-boundary.tsx | 12 ++--- 5 files changed, 37 insertions(+), 48 deletions(-) diff --git a/frontend/app/api/errors/route.test.ts b/frontend/app/api/errors/route.test.ts index a933e99..5774181 100644 --- a/frontend/app/api/errors/route.test.ts +++ b/frontend/app/api/errors/route.test.ts @@ -33,13 +33,13 @@ beforeEach(() => { if (shouldInsertFail) { return new Response(JSON.stringify({ error: { message: "DB insert failed" } }), { status: 400, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }) } return new Response(JSON.stringify([{ id: "mock-id" }]), { status: 201, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }) }) as typeof globalThis.fetch }) @@ -63,7 +63,6 @@ function createRequest(body: unknown, headers?: Record): Request // Tests // --------------------------------------------------------------------------- - test("POST /api/errors — returns 400 when message is missing", async () => { const req = createRequest({ stack: "some stack" }) // Cast to NextRequest-compatible since the route expects NextRequest @@ -100,7 +99,7 @@ test("POST /api/errors — returns 201 on valid payload", async () => { assert.strictEqual(inserted.status, "failed") assert.ok( (inserted.error_message as string).includes("Test component crashed"), - "error_message should contain the original message", + "error_message should contain the original message" ) }) @@ -131,10 +130,7 @@ test("POST /api/errors — returns 500 when Supabase insert fails", async () => test("POST /api/errors — rate-limits after 5 requests from same IP", async () => { // Send 5 requests (should all succeed) for (let i = 0; i < 5; i++) { - const req = createRequest( - { message: `Error ${i}` }, - { "x-forwarded-for": "192.168.1.100" }, - ) + const req = createRequest({ message: `Error ${i}` }, { "x-forwarded-for": "192.168.1.100" }) const res = await POST(req as unknown as Parameters[0]) assert.strictEqual(res.status, 201, `Request ${i + 1} should succeed`) } @@ -142,7 +138,7 @@ test("POST /api/errors — rate-limits after 5 requests from same IP", async () // 6th request should be rate-limited const req = createRequest( { message: "Error 6 — should be blocked" }, - { "x-forwarded-for": "192.168.1.100" }, + { "x-forwarded-for": "192.168.1.100" } ) const res = await POST(req as unknown as Parameters[0]) assert.strictEqual(res.status, 429) diff --git a/frontend/app/api/errors/route.ts b/frontend/app/api/errors/route.ts index e5ce57b..c327fb1 100644 --- a/frontend/app/api/errors/route.ts +++ b/frontend/app/api/errors/route.ts @@ -60,7 +60,7 @@ export async function POST(req: NextRequest) { if (!message || typeof message !== "string") { return NextResponse.json( { error: "VALIDATION_ERROR", message: "Missing or invalid 'message' field." }, - { status: 400 }, + { status: 400 } ) } @@ -69,7 +69,7 @@ export async function POST(req: NextRequest) { if (isServerRateLimited(key)) { return NextResponse.json( { error: "TOO_MANY_REQUESTS", message: "Error reporting rate limit exceeded." }, - { status: 429 }, + { status: 429 } ) } @@ -98,7 +98,7 @@ export async function POST(req: NextRequest) { console.error("[POST /api/errors] Supabase insert failed:", dbError) return NextResponse.json( { error: "DB_ERROR", message: "Failed to log error." }, - { status: 500 }, + { status: 500 } ) } @@ -106,7 +106,7 @@ export async function POST(req: NextRequest) { } catch { return NextResponse.json( { error: "INTERNAL_ERROR", message: "Unexpected server error." }, - { status: 500 }, + { status: 500 } ) } } diff --git a/frontend/app/dashboard/create/[type]/page.tsx b/frontend/app/dashboard/create/[type]/page.tsx index 9568f6b..a16133c 100644 --- a/frontend/app/dashboard/create/[type]/page.tsx +++ b/frontend/app/dashboard/create/[type]/page.tsx @@ -66,33 +66,33 @@ export default function CreateGroupPage({ params }: { params: Promise<{ type: st return (
- -
-
- + +
+
+ - -

- {prefill ? `New Cycle: ${prefill.name}` : titles[type as keyof typeof titles]} -

-

- {prefill - ? "Pre-filled from the original pool. Edit any values before creating." - : "Fill in the details to create your savings group"} -

+ +

+ {prefill ? `New Cycle: ${prefill.name}` : titles[type as keyof typeof titles]} +

+

+ {prefill + ? "Pre-filled from the original pool. Edit any values before creating." + : "Fill in the details to create your savings group"} +

- {type === "rotational" && } - {type === "target" && } - {type === "flexible" && } -
-
-
-
+ {type === "rotational" && } + {type === "target" && } + {type === "flexible" && } + +
+ +
) } diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index 8815472..3116470 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -146,4 +146,3 @@ export default function GroupPage({ params }: { params: Promise<{ id: string }> ) } - diff --git a/frontend/components/error-boundary.tsx b/frontend/components/error-boundary.tsx index c9b7513..6279d6d 100644 --- a/frontend/components/error-boundary.tsx +++ b/frontend/components/error-boundary.tsx @@ -48,7 +48,7 @@ export class ErrorBoundary extends Component
-

- Failed to load {label} -

+

Failed to load {label}

An unexpected error occurred in this section.

@@ -142,11 +140,7 @@ export class ErrorBoundary extends Component - {showDetails ? ( - - ) : ( - - )} + {showDetails ? : } {showDetails ? "Hide error details" : "Show error details"}