Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/app/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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(
<RootLayout>
<div />
</RootLayout>
);
expect(useGlobalErrorCaptureMock).toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -92,6 +93,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
Skip to main content
</a>
<I18nProvider locale={DEFAULT_LOCALE}>
<GlobalErrorCapture />
<WalletHydrator />
{children}
<ToastViewport />
Expand Down
17 changes: 17 additions & 0 deletions src/components/GlobalErrorCapture.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
171 changes: 171 additions & 0 deletions src/hooks/useGlobalErrorCapture.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, EventListener>();
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<string, EventListener>();
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<string, EventListener>();
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();
});
});
59 changes: 59 additions & 0 deletions src/hooks/useGlobalErrorCapture.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}, []);
}