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
5 changes: 5 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ NEXT_PUBLIC_API_URL=http://localhost:3000
# Contract addresses shown on the contracts page
NEXT_PUBLIC_PREDICTION_CONTRACT=
NEXT_PUBLIC_REWARD_CONTRACT=

# Where the "Report issue" action on a route error sends the user.
# Accepts a tracker URL or a mailto: address; the error context is appended
# as query parameters. Defaults to this project's GitHub issue tracker.
NEXT_PUBLIC_ERROR_REPORT_URL=
181 changes: 180 additions & 1 deletion frontend/src/component/route-error-state.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { RouteErrorState } from "./route-error-state";
import {
RouteErrorState,
buildReportBody,
buildReportUrl,
isNotFoundError,
} from "./route-error-state";

const REPORT_BASE = "https://example.test/issues/new";

function makeError(
message: string,
extras: Record<string, unknown> = {},
): Error & { digest?: string } {
return Object.assign(new Error(message), extras);
}

/** Reads the prefilled body back out of a report href. */
function reportParams(href: string): URLSearchParams {
return new URLSearchParams(href.slice(href.indexOf("?") + 1));
}

describe("RouteErrorState", () => {
afterEach(() => {
Expand Down Expand Up @@ -43,3 +62,163 @@ describe("RouteErrorState", () => {
expect(reset).toHaveBeenCalledTimes(1);
});
});

describe("isNotFoundError", () => {
it("recognises the digest Next.js attaches to notFound()", () => {
expect(isNotFoundError(makeError("x", { digest: "NEXT_NOT_FOUND" }))).toBe(true);
});

it("recognises an explicit 404 status from a loader", () => {
expect(isNotFoundError(makeError("Not Found", { status: 404 }))).toBe(true);
expect(isNotFoundError(makeError("Not Found", { statusCode: 404 }))).toBe(true);
});

it("does not treat an ordinary runtime error as a missing page", () => {
expect(isNotFoundError(makeError("Could not load"))).toBe(false);
expect(isNotFoundError(makeError("boom", { digest: "error-reference" }))).toBe(false);
});

it("does not misclassify an error that merely mentions 404", () => {
// A substring match here would send a real failure to the 404 screen.
expect(isNotFoundError(makeError("upstream returned 404 for a sub-resource"))).toBe(
false,
);
expect(isNotFoundError(makeError("x", { status: 500 }))).toBe(false);
});
});

describe("buildReportBody", () => {
const context = {
routeLabel: "Dashboard",
message: "Could not load",
digest: "error-reference",
path: "/dashboard",
occurredAt: "2026-01-01T00:00:00.000Z",
};

it("includes what a maintainer needs to triage", () => {
const body = buildReportBody(context);
expect(body).toContain("Route: Dashboard");
expect(body).toContain("Path: /dashboard");
expect(body).toContain("Reference: error-reference");
expect(body).toContain("2026-01-01T00:00:00.000Z");
expect(body).toContain("Could not load");
});

it("says so explicitly when there is no digest", () => {
expect(buildReportBody({ ...context, digest: undefined })).toContain(
"Reference: none",
);
});

it("omits the path line rather than printing undefined", () => {
const body = buildReportBody({ ...context, path: undefined });
expect(body).not.toContain("Path:");
expect(body).not.toContain("undefined");
});

it("handles an error with no message", () => {
expect(buildReportBody({ ...context, message: "" })).toContain("(no message)");
});
});

describe("buildReportUrl", () => {
const context = {
routeLabel: "Dashboard",
message: "Could not load",
digest: "error-reference",
occurredAt: "2026-01-01T00:00:00.000Z",
};

it("prefills title and body for a tracker URL", () => {
const href = buildReportUrl(REPORT_BASE, context);
const params = reportParams(href);

expect(href.startsWith(`${REPORT_BASE}?`)).toBe(true);
expect(params.get("title")).toBe("[Route error] Dashboard (error-reference)");
expect(params.get("body")).toContain("error-reference");
});

it("uses subject instead of title for a mailto address", () => {
const params = reportParams(buildReportUrl("mailto:support@example.test", context));

expect(params.get("subject")).toBe("[Route error] Dashboard (error-reference)");
expect(params.get("title")).toBeNull();
expect(params.get("body")).toContain("Could not load");
});

it("appends to a base URL that already has a query string", () => {
const href = buildReportUrl(`${REPORT_BASE}?labels=bug`, context);
expect(href).toContain("?labels=bug&");
expect(reportParams(href).get("labels")).toBe("bug");
});

it("leaves the digest out of the title when there is none", () => {
const params = reportParams(
buildReportUrl(REPORT_BASE, { ...context, digest: undefined }),
);
expect(params.get("title")).toBe("[Route error] Dashboard");
});
});

describe("RouteErrorState — report and not-found", () => {
afterEach(() => {
vi.restoreAllMocks();
});

function renderError(error: Error & { digest?: string }) {
return render(
<RouteErrorState
error={error}
reset={vi.fn()}
routeLabel="Dashboard"
description="Please retry."
fullScreen={false}
reportUrl={REPORT_BASE}
/>,
);
}

it("offers a report link carrying the digest", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
renderError(makeError("Could not load", { digest: "error-reference" }));

const link = screen.getByRole("link", { name: /report issue/i });
const href = link.getAttribute("href") ?? "";

expect(href.startsWith(REPORT_BASE)).toBe(true);
expect(reportParams(href).get("body")).toContain("error-reference");
expect(link).toHaveAttribute("target", "_blank");
expect(link.getAttribute("rel")).toContain("noopener");
});

it("still offers a report link when the error has no digest", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
renderError(makeError("Could not load"));

const href =
screen.getByRole("link", { name: /report issue/i }).getAttribute("href") ?? "";
expect(reportParams(href).get("body")).toContain("Reference: none");
});

it("shows the 404 screen for a notFound() error instead of a crash message", () => {
renderError(makeError("not found", { digest: "NEXT_NOT_FOUND" }));

expect(screen.getByText("Page Not Found")).toBeInTheDocument();
expect(
screen.queryByText("Dashboard hit an unexpected problem"),
).not.toBeInTheDocument();
// Retry would never succeed for a URL that does not exist.
expect(screen.queryByRole("button", { name: /try again/i })).not.toBeInTheDocument();
});

it("does not log a missing page as an application fault", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);

renderError(makeError("not found", { digest: "NEXT_NOT_FOUND" }));

expect(consoleError).not.toHaveBeenCalled();
});
});
120 changes: 117 additions & 3 deletions frontend/src/component/route-error-state.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,135 @@
"use client";

import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import Link from "next/link";
import { AlertTriangle, Home, RefreshCcw } from "lucide-react";
import { AlertTriangle, Flag, Home, RefreshCcw } from "lucide-react";

import { AppNotFound } from "@/component/app-not-found";
import { Button } from "@/component/ui/button";
import { env } from "@/lib/env";

type RouteErrorStateProps = {
error: Error & { digest?: string };
reset: () => void;
routeLabel: string;
description: string;
fullScreen?: boolean;
/** Overrides `env.ERROR_REPORT_URL`; mainly a seam for tests. */
reportUrl?: string;
};

/** Digest Next.js attaches to the error thrown by `notFound()`. */
const NEXT_NOT_FOUND_DIGEST = "NEXT_NOT_FOUND";

/**
* Whether this error is really a missing page rather than a fault.
*
* A route that calls `notFound()`, or a loader that surfaces a 404 from the
* API, lands in the same error boundary as a genuine crash. Showing "hit an
* unexpected problem" for a mistyped URL invites the user to retry something
* that will never succeed, so those cases are routed to the 404 treatment.
*
* Deliberately narrow: it matches Next's own digest and an explicit numeric
* status, not the substring "404" anywhere in a message, which would
* misclassify a real failure that merely mentions the number.
*/
export function isNotFoundError(error: Error & { digest?: string }): boolean {
if (error.digest === NEXT_NOT_FOUND_DIGEST) return true;

const withStatus = error as { status?: unknown; statusCode?: unknown };
return withStatus.status === 404 || withStatus.statusCode === 404;
}

/** Fields a report carries. Kept to what a maintainer needs to triage. */
export interface ErrorReportContext {
routeLabel: string;
message: string;
digest?: string;
/** Path the failure happened on; omitted during server rendering. */
path?: string;
occurredAt: string;
}

export function buildReportBody(context: ErrorReportContext): string {
const lines = [
`Route: ${context.routeLabel}`,
context.path ? `Path: ${context.path}` : null,
`Reference: ${context.digest ?? "none"}`,
`Time: ${context.occurredAt}`,
"",
"Error message:",
context.message || "(no message)",
"",
"What were you doing when this happened?",
];
return lines.filter((line) => line !== null).join("\n");
}

/**
* Report destination with the context prefilled.
*
* Handles both a tracker URL (`?title=&body=`) and a `mailto:` address
* (`?subject=&body=`), since a deployment may point `ERROR_REPORT_URL` at
* either. The stack trace is deliberately left out: it can carry values from
* the failing request, and this text is handed to a third party.
*/
export function buildReportUrl(
baseUrl: string,
context: ErrorReportContext,
): string {
const title = `[Route error] ${context.routeLabel}${
context.digest ? ` (${context.digest})` : ""
}`;
const body = buildReportBody(context);

const isMailto = baseUrl.startsWith("mailto:");
const separator = baseUrl.includes("?") ? "&" : "?";
const params = new URLSearchParams(
isMailto ? { subject: title, body } : { title, body },
);

return `${baseUrl}${separator}${params.toString()}`;
}

export function RouteErrorState({
error,
reset,
routeLabel,
description,
fullScreen = true,
reportUrl,
}: RouteErrorStateProps) {
const notFound = isNotFoundError(error);

useEffect(() => {
// A missing page is not a fault worth logging as one.
if (notFound) return;

console.error(`[Route Error Boundary] ${routeLabel}`, {
message: error.message,
digest: error.digest,
error,
});
}, [error, routeLabel]);
}, [error, routeLabel, notFound]);

// `occurredAt` is captured per render rather than in state: the boundary is
// mounted when the failure happens, so this is the failure time, and keeping
// it out of state avoids an extra render on mount.
const reportHref = useMemo(
() =>
buildReportUrl(reportUrl ?? env.ERROR_REPORT_URL, {
routeLabel,
message: error.message,
digest: error.digest,
path: typeof window === "undefined" ? undefined : window.location.pathname,
occurredAt: new Date().toISOString(),
}),
[error.message, error.digest, routeLabel, reportUrl],
);

if (notFound) {
return <AppNotFound compact={!fullScreen} />;
}

return (
<section className="dark relative overflow-hidden rounded-[2rem] border border-white/10 bg-[#171d2d] text-white shadow-[0_25px_80px_rgba(1,6,20,0.45)]">
Expand Down Expand Up @@ -72,6 +174,18 @@ export function RouteErrorState({
Back to home
</Link>
</Button>
<Button
asChild
variant="outline"
className="h-11 rounded-xl border-white/10 bg-white/5 px-6 text-sm font-medium text-white hover:bg-white/10 hover:text-white"
>
{/* Opens the tracker in a new tab so an in-flight recovery
attempt on this page is not thrown away. */}
<a href={reportHref} target="_blank" rel="noreferrer noopener">
<Flag className="h-4 w-4" />
Report issue
</a>
</Button>
</div>

{error.digest ? (
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export const env = {
STELLAR_EXPLORER_URL:
process.env.NEXT_PUBLIC_STELLAR_EXPLORER_URL ?? "https://stellar.expert/explorer",
STELLAR_NETWORK: process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? "testnet",
/**
* Where "Report issue" sends the user. Defaults to this project's own
* tracker; a deployment with its own support desk can point it elsewhere,
* including at a `mailto:` address.
*/
ERROR_REPORT_URL:
process.env.NEXT_PUBLIC_ERROR_REPORT_URL ??
"https://github.com/Arena1X/InsightArena/issues/new",
};

export function getStellarExplorerUrl(
Expand Down