diff --git a/frontend/.env.example b/frontend/.env.example index 0a9c9047..aa6504a9 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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= diff --git a/frontend/src/component/route-error-state.test.tsx b/frontend/src/component/route-error-state.test.tsx index d429d7ca..d302c449 100644 --- a/frontend/src/component/route-error-state.test.tsx +++ b/frontend/src/component/route-error-state.test.tsx @@ -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 = {}, +): 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(() => { @@ -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( + , + ); + } + + 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(); + }); +}); diff --git a/frontend/src/component/route-error-state.tsx b/frontend/src/component/route-error-state.tsx index d353aec6..48cefa43 100644 --- a/frontend/src/component/route-error-state.tsx +++ b/frontend/src/component/route-error-state.tsx @@ -1,10 +1,12 @@ "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 }; @@ -12,22 +14,122 @@ type RouteErrorStateProps = { 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 ; + } return (
@@ -72,6 +174,18 @@ export function RouteErrorState({ Back to home + {error.digest ? ( diff --git a/frontend/src/lib/env.ts b/frontend/src/lib/env.ts index 787fc06a..d3d5da5d 100644 --- a/frontend/src/lib/env.ts +++ b/frontend/src/lib/env.ts @@ -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(