From e4ee28c6a5e49d223c0cc297f144f470d6247a2a Mon Sep 17 00:00:00 2001 From: MiraBello Date: Sun, 30 Aug 2026 08:19:47 +0000 Subject: [PATCH] feat(app): add global unhandled-error capture hook (#272) - Add useGlobalErrorCapture hook that attaches window 'error' and 'unhandledrejection' listeners and forwards them to the toast store - Add renderless GlobalErrorCapture client component wrapping the hook - Mount GlobalErrorCapture inside RootLayout so it covers all routes - Messages are truncated to 120 chars and also logged via console.error - 10 unit tests covering mount/unmount, Error objects, string reasons, null reasons, truncation, and console.error output --- src/app/layout.test.tsx | 21 ++- src/app/layout.tsx | 2 + src/components/GlobalErrorCapture.tsx | 17 +++ src/hooks/useGlobalErrorCapture.test.ts | 171 ++++++++++++++++++++++++ src/hooks/useGlobalErrorCapture.ts | 59 ++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/components/GlobalErrorCapture.tsx create mode 100644 src/hooks/useGlobalErrorCapture.test.ts create mode 100644 src/hooks/useGlobalErrorCapture.ts diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx index 27d6e9d..9c93f17 100644 --- a/src/app/layout.test.tsx +++ b/src/app/layout.test.tsx @@ -1,5 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; + +const { useGlobalErrorCaptureMock } = vi.hoisted(() => ({ + useGlobalErrorCaptureMock: vi.fn(), +})); + +// Stub out the global error capture hook — we test it in isolation +vi.mock("@/hooks/useGlobalErrorCapture", () => ({ + useGlobalErrorCapture: useGlobalErrorCaptureMock, +})); + import RootLayout from "./layout"; describe("RootLayout", () => { @@ -22,4 +32,13 @@ describe("RootLayout", () => { expect(screen.getByText("Skip to main content")).toHaveAttribute("href", "#main-content"); }); + + it("mounts the global error capture hook", () => { + render( + +
+ + ); + expect(useGlobalErrorCaptureMock).toHaveBeenCalled(); + }); }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 543ea82..0cc934b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { WalletHydrator } from "@/components/WalletHydrator"; import { ToastViewport } from "@/components/ToastViewport"; +import { GlobalErrorCapture } from "@/components/GlobalErrorCapture"; import { I18nProvider } from "@/lib/i18n/I18nProvider"; import { DEFAULT_LOCALE } from "@/lib/i18n"; @@ -92,6 +93,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) Skip to main content + {children} diff --git a/src/components/GlobalErrorCapture.tsx b/src/components/GlobalErrorCapture.tsx new file mode 100644 index 0000000..33dd6ec --- /dev/null +++ b/src/components/GlobalErrorCapture.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useGlobalErrorCapture } from "@/hooks/useGlobalErrorCapture"; + +/** + * GlobalErrorCapture + * + * A renderless client component that mounts the global error and + * unhandledrejection listeners for the lifetime of the application. + * + * Place it once, high in the tree (e.g. inside RootLayout), so that it is + * always mounted regardless of which route is active. + */ +export function GlobalErrorCapture(): null { + useGlobalErrorCapture(); + return null; +} diff --git a/src/hooks/useGlobalErrorCapture.test.ts b/src/hooks/useGlobalErrorCapture.test.ts new file mode 100644 index 0000000..52812c9 --- /dev/null +++ b/src/hooks/useGlobalErrorCapture.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +const { addToastMock } = vi.hoisted(() => ({ + addToastMock: vi.fn(), +})); + +vi.mock("@/store/toast", () => ({ + useToastStore: { getState: () => ({ addToast: addToastMock }) }, +})); + +import { useGlobalErrorCapture } from "./useGlobalErrorCapture"; + +describe("useGlobalErrorCapture", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers window 'error' and 'unhandledrejection' listeners on mount", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + renderHook(() => useGlobalErrorCapture()); + + expect(addSpy).toHaveBeenCalledWith("error", expect.any(Function)); + expect(addSpy).toHaveBeenCalledWith("unhandledrejection", expect.any(Function)); + }); + + it("removes both listeners on unmount", () => { + const removeSpy = vi.spyOn(window, "removeEventListener"); + const { unmount } = renderHook(() => useGlobalErrorCapture()); + unmount(); + + expect(removeSpy).toHaveBeenCalledWith("error", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("unhandledrejection", expect.any(Function)); + }); + + it("forwards a synchronous ErrorEvent to the toast store", () => { + renderHook(() => useGlobalErrorCapture()); + + act(() => { + const event = new ErrorEvent("error", { + error: new Error("Something exploded"), + message: "Something exploded", + }); + window.dispatchEvent(event); + }); + + expect(addToastMock).toHaveBeenCalledWith("Something exploded", "error"); + }); + + it("handles an ErrorEvent with no error object (message fallback)", () => { + renderHook(() => useGlobalErrorCapture()); + + act(() => { + const event = new ErrorEvent("error", { message: "script error from string" }); + window.dispatchEvent(event); + }); + + expect(addToastMock).toHaveBeenCalledWith("script error from string", "error"); + }); + + it("invokes the unhandledrejection handler with an Error reason", () => { + // jsdom does not define PromiseRejectionEvent, so we call the listener + // directly by capturing it from addEventListener. + const handlers = new Map(); + const addSpy = vi.spyOn(window, "addEventListener").mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject) => { + handlers.set(type, listener as EventListener); + } + ); + + renderHook(() => useGlobalErrorCapture()); + + const handler = handlers.get("unhandledrejection"); + expect(handler).toBeDefined(); + + act(() => { + handler!({ type: "unhandledrejection", reason: new Error("Promise blew up") } as unknown as Event); + }); + + expect(addToastMock).toHaveBeenCalledWith("Promise blew up", "error"); + addSpy.mockRestore(); + }); + + it("handles a string rejection reason", () => { + const handlers = new Map(); + const addSpy = vi.spyOn(window, "addEventListener").mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject) => { + handlers.set(type, listener as EventListener); + } + ); + + renderHook(() => useGlobalErrorCapture()); + + const handler = handlers.get("unhandledrejection")!; + act(() => { + handler({ type: "unhandledrejection", reason: "plain string error" } as unknown as Event); + }); + + expect(addToastMock).toHaveBeenCalledWith("plain string error", "error"); + addSpy.mockRestore(); + }); + + it("uses a fallback message for null rejection reasons", () => { + const handlers = new Map(); + const addSpy = vi.spyOn(window, "addEventListener").mockImplementation( + (type: string, listener: EventListenerOrEventListenerObject) => { + handlers.set(type, listener as EventListener); + } + ); + + renderHook(() => useGlobalErrorCapture()); + + const handler = handlers.get("unhandledrejection")!; + act(() => { + handler({ type: "unhandledrejection", reason: null } as unknown as Event); + }); + + expect(addToastMock).toHaveBeenCalledWith("An unexpected error occurred.", "error"); + addSpy.mockRestore(); + }); + + it("truncates very long error messages", () => { + renderHook(() => useGlobalErrorCapture()); + + const longMessage = "x".repeat(200); + act(() => { + const event = new ErrorEvent("error", { + error: new Error(longMessage), + message: longMessage, + }); + window.dispatchEvent(event); + }); + + const [toastMsg] = addToastMock.mock.calls[0] as [string, string]; + expect(toastMsg.length).toBeLessThanOrEqual(121); // 120 chars + ellipsis + expect(toastMsg.endsWith("…")).toBe(true); + }); + + it("does not fire after the component unmounts", () => { + // Spy on removeEventListener to confirm it is called on cleanup. + const removeSpy = vi.spyOn(window, "removeEventListener"); + const { unmount } = renderHook(() => useGlobalErrorCapture()); + + unmount(); + + // Both listeners must be deregistered so no more toasts appear. + expect(removeSpy).toHaveBeenCalledWith("error", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("unhandledrejection", expect.any(Function)); + // No toasts were fired during this test. + expect(addToastMock).not.toHaveBeenCalled(); + }); + + it("also logs via console.error", () => { + renderHook(() => useGlobalErrorCapture()); + + act(() => { + const event = new ErrorEvent("error", { + error: new Error("logged error"), + message: "logged error", + }); + window.dispatchEvent(event); + }); + + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useGlobalErrorCapture.ts b/src/hooks/useGlobalErrorCapture.ts new file mode 100644 index 0000000..dbd79aa --- /dev/null +++ b/src/hooks/useGlobalErrorCapture.ts @@ -0,0 +1,59 @@ +/** + * useGlobalErrorCapture + * + * Attaches window-level listeners for two browser error channels that React + * does NOT intercept automatically: + * + * - "error" — synchronous errors thrown in event handlers, + * setTimeout/setInterval callbacks, and any context + * outside React's render cycle. + * - "unhandledrejection" — Promises that are rejected without a .catch() or + * try/catch — common in fire-and-forget async calls. + * + * Both are forwarded to the shared toast store as "error" notifications and + * also logged via console.error so they still appear in DevTools. + * + * The hook is intentionally side-effect-only (no return value) and is safe + * to call at the top of the component tree (e.g. RootLayout's client shell). + */ +"use client"; + +import { useEffect } from "react"; +import { useToastStore } from "@/store/toast"; + +/** Maximum characters shown in the toast for an error message. */ +const MAX_MSG_LENGTH = 120; + +function truncate(msg: string): string { + return msg.length <= MAX_MSG_LENGTH ? msg : `${msg.slice(0, MAX_MSG_LENGTH)}…`; +} + +function extractMessage(err: unknown): string { + if (err instanceof Error) return err.message || err.toString(); + if (typeof err === "string" && err.trim()) return err; + return "An unexpected error occurred."; +} + +export function useGlobalErrorCapture(): void { + useEffect(() => { + const handleError = (event: ErrorEvent): void => { + const message = extractMessage(event.error ?? event.message); + console.error("[GlobalErrorCapture] Uncaught error:", event.error ?? event.message); + useToastStore.getState().addToast(truncate(message), "error"); + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent): void => { + const message = extractMessage(event.reason); + console.error("[GlobalErrorCapture] Unhandled rejection:", event.reason); + useToastStore.getState().addToast(truncate(message), "error"); + }; + + window.addEventListener("error", handleError); + window.addEventListener("unhandledrejection", handleUnhandledRejection); + + return () => { + window.removeEventListener("error", handleError); + window.removeEventListener("unhandledrejection", handleUnhandledRejection); + }; + }, []); +}