Skip to content

Commit 3e48edd

Browse files
committed
fix(webapp): harden RouteErrorDisplay against null error.data
Remix route errors can have null/empty data; reading .message threw inside the error boundary and blanked the page. Fall back safely.
1 parent de65370 commit 3e48edd

4 files changed

Lines changed: 62 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Prevent dashboard error pages from crashing when a route error has no data payload

apps/webapp/app/components/ErrorDisplay.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { HomeIcon } from "@heroicons/react/20/solid";
22
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
3-
import { friendlyErrorDisplay } from "~/utils/httpErrors";
3+
import { friendlyErrorDisplay, getRouteErrorMessage } from "~/utils/httpErrors";
44
import { permissionDeniedMessage } from "~/utils/permissionDenied";
55
import { LinkButton } from "./primitives/Buttons";
66
import { Header1 } from "./primitives/Headers";
@@ -39,9 +39,7 @@ export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
3939
{isRouteErrorResponse(error) ? (
4040
<ErrorDisplay
4141
title={friendlyErrorDisplay(error.status, error.statusText).title}
42-
message={
43-
error.data.message ?? friendlyErrorDisplay(error.status, error.statusText).message
44-
}
42+
message={getRouteErrorMessage(error.status, error.statusText, error.data)}
4543
{...options}
4644
/>
4745
) : error instanceof Error ? (
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getRouteErrorMessage } from "./httpErrors";
3+
4+
describe("getRouteErrorMessage", () => {
5+
it("returns data.message when present", () => {
6+
expect(getRouteErrorMessage(500, "Internal Server Error", { message: "boom" })).toBe("boom");
7+
});
8+
9+
it("falls back when data is null", () => {
10+
expect(getRouteErrorMessage(500, "Internal Server Error", null)).toBe(
11+
"Something went wrong on our end. Please try again later."
12+
);
13+
});
14+
15+
it("falls back when data is undefined", () => {
16+
expect(getRouteErrorMessage(404, "Not Found", undefined)).toBe(
17+
"The page you're looking for doesn't exist."
18+
);
19+
});
20+
21+
it("uses a string data body", () => {
22+
expect(getRouteErrorMessage(400, "Bad Request", "Invalid payload")).toBe("Invalid payload");
23+
});
24+
25+
it("falls back when message is missing or empty", () => {
26+
expect(getRouteErrorMessage(403, "Forbidden", {})).toBe(
27+
"You don't have permission to access this resource."
28+
);
29+
expect(getRouteErrorMessage(403, "Forbidden", { message: "" })).toBe(
30+
"You don't have permission to access this resource."
31+
);
32+
});
33+
});

apps/webapp/app/utils/httpErrors.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,24 @@ export function friendlyErrorDisplay(statusCode: number, statusText?: string) {
4141
};
4242
}
4343
}
44+
45+
/**
46+
* Safely extract a user-facing message from a Remix route error response.
47+
* `error.data` can be null, a string, or an object — never assume `.message`.
48+
*/
49+
export function getRouteErrorMessage(status: number, statusText: string, data: unknown): string {
50+
const fallback = friendlyErrorDisplay(status, statusText).message;
51+
52+
if (typeof data === "string" && data.length > 0) {
53+
return data;
54+
}
55+
56+
if (data && typeof data === "object" && "message" in data) {
57+
const message = (data as { message: unknown }).message;
58+
if (typeof message === "string" && message.length > 0) {
59+
return message;
60+
}
61+
}
62+
63+
return fallback;
64+
}

0 commit comments

Comments
 (0)