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);
+ };
+ }, []);
+}