Skip to content

Commit e20d99e

Browse files
committed
Implement Nimbus error handling components and utilities for consistent user-facing error messages
1 parent 19be637 commit e20d99e

File tree

16 files changed

+1227
-0
lines changed

16 files changed

+1227
-0
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import {
4+
parseIntegerSearchParam,
5+
setParamsBlobValue,
6+
} from "../searchParamsUtils";
7+
8+
describe("setParamsBlobValue", () => {
9+
it("returns a function that updates the blob with the new value", () => {
10+
const setter = vi.fn();
11+
const oldBlob = { foo: "bar", baz: 1 };
12+
const key = "foo";
13+
14+
const updateFoo = setParamsBlobValue(setter, oldBlob, key);
15+
updateFoo("qux");
16+
17+
expect(setter).toHaveBeenCalledWith({ foo: "qux", baz: 1 });
18+
});
19+
20+
it("adds a new key if it did not exist", () => {
21+
const setter = vi.fn();
22+
const oldBlob: { foo: string; baz?: number } = { foo: "bar" };
23+
const key = "baz";
24+
25+
const updateBaz = setParamsBlobValue(setter, oldBlob, key);
26+
updateBaz(2);
27+
28+
expect(setter).toHaveBeenCalledWith({ foo: "bar", baz: 2 });
29+
});
30+
});
31+
32+
describe("parseIntegerSearchParam", () => {
33+
it("returns undefined for null", () => {
34+
expect(parseIntegerSearchParam(null)).toBeUndefined();
35+
});
36+
37+
it("returns undefined for empty string", () => {
38+
expect(parseIntegerSearchParam("")).toBeUndefined();
39+
});
40+
41+
it("returns undefined for whitespace string", () => {
42+
expect(parseIntegerSearchParam(" ")).toBeUndefined();
43+
});
44+
45+
it("returns undefined for non-numeric string", () => {
46+
expect(parseIntegerSearchParam("abc")).toBeUndefined();
47+
expect(parseIntegerSearchParam("123a")).toBeUndefined();
48+
expect(parseIntegerSearchParam("a123")).toBeUndefined();
49+
});
50+
51+
it("returns undefined for float string", () => {
52+
expect(parseIntegerSearchParam("12.34")).toBeUndefined();
53+
});
54+
55+
it("returns integer for valid integer string", () => {
56+
expect(parseIntegerSearchParam("123")).toBe(123);
57+
expect(parseIntegerSearchParam("0")).toBe(0);
58+
expect(parseIntegerSearchParam(" 456 ")).toBe(456);
59+
});
60+
61+
it("returns undefined for unsafe integers", () => {
62+
// Number.MAX_SAFE_INTEGER is 9007199254740991
63+
const unsafe = "9007199254740992";
64+
expect(parseIntegerSearchParam(unsafe)).toBeUndefined();
65+
});
66+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
@layer nimbus-components {
2+
.nimbus-error-boundary {
3+
display: flex;
4+
flex-direction: column;
5+
gap: var(--gap-sm);
6+
align-items: flex-start;
7+
padding: var(--space-20);
8+
border: 1px solid rgb(61 78 159 / 0.35);
9+
border-radius: var(--radius-lg);
10+
background: linear-gradient(
11+
135deg,
12+
rgb(29 41 95 / 0.35),
13+
rgb(19 26 68 / 0.5)
14+
);
15+
transition:
16+
background 180ms ease,
17+
border-color 180ms ease,
18+
box-shadow 180ms ease;
19+
}
20+
21+
.nimbus-error-boundary:hover {
22+
border-color: rgb(103 140 255 / 0.55);
23+
background: linear-gradient(
24+
135deg,
25+
rgb(40 58 132 / 0.45),
26+
rgb(24 32 86 / 0.65)
27+
);
28+
box-shadow: 0 0.5rem 1.25rem rgb(29 36 82 / 0.45);
29+
}
30+
31+
.nimbus-error-boundary__actions {
32+
display: flex;
33+
gap: var(--gap-xs);
34+
align-self: stretch;
35+
}
36+
37+
.nimbus-error-boundary__button {
38+
flex-grow: 1;
39+
}
40+
41+
.nimbus-error-boundary__description {
42+
color: var(--color-text-secondary);
43+
line-height: var(--line-height-md);
44+
}
45+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { Component, type ErrorInfo, type ReactNode, useCallback } from "react";
2+
import { useAsyncError, useLocation, useRouteError } from "react-router";
3+
4+
import { NewButton } from "@thunderstore/cyberstorm";
5+
import { classnames } from "@thunderstore/cyberstorm";
6+
7+
import {
8+
resolveRouteErrorPayload,
9+
safeResolveRouteErrorPayload,
10+
} from "../resolveRouteErrorPayload";
11+
import "./NimbusErrorBoundary.css";
12+
13+
interface NimbusErrorBoundaryState {
14+
error: Error | null;
15+
}
16+
17+
export interface NimbusErrorRetryHandlerArgs {
18+
error: unknown;
19+
reset: () => void;
20+
}
21+
22+
export interface NimbusErrorBoundaryProps {
23+
children: ReactNode;
24+
title?: string;
25+
description?: string;
26+
retryLabel?: string;
27+
onError?: (error: Error, info: ErrorInfo) => void;
28+
onReset?: () => void;
29+
fallback?: React.ComponentType<NimbusErrorBoundaryFallbackProps>;
30+
onRetry?: (args: NimbusErrorRetryHandlerArgs) => void;
31+
fallbackClassName?: string;
32+
}
33+
34+
export interface NimbusErrorBoundaryFallbackProps {
35+
error: unknown;
36+
reset?: () => void;
37+
title?: string;
38+
description?: string;
39+
retryLabel?: string;
40+
onRetry?: (args: NimbusErrorRetryHandlerArgs) => void;
41+
className?: string;
42+
}
43+
44+
export type NimbusAwaitErrorElementProps = Pick<
45+
NimbusErrorBoundaryFallbackProps,
46+
"title" | "description" | "retryLabel" | "className" | "onRetry"
47+
>;
48+
49+
/**
50+
* NimbusErrorBoundary isolates rendering failures within a subtree and surfaces
51+
* a consistent recovery UI with an optional "Retry" affordance.
52+
*/
53+
export class NimbusErrorBoundary extends Component<
54+
NimbusErrorBoundaryProps,
55+
NimbusErrorBoundaryState
56+
> {
57+
public state: NimbusErrorBoundaryState = {
58+
error: null,
59+
};
60+
61+
public static getDerivedStateFromError(
62+
error: Error
63+
): NimbusErrorBoundaryState {
64+
return { error };
65+
}
66+
67+
public componentDidCatch(error: Error, info: ErrorInfo) {
68+
this.props.onError?.(error, info);
69+
}
70+
71+
private readonly resetBoundary = () => {
72+
this.setState({ error: null }, () => {
73+
this.props.onReset?.();
74+
});
75+
};
76+
77+
public override render() {
78+
const { error } = this.state;
79+
80+
if (error) {
81+
const FallbackComponent =
82+
this.props.fallback ?? NimbusErrorBoundaryFallback;
83+
84+
return (
85+
<FallbackComponent
86+
error={error}
87+
reset={this.resetBoundary}
88+
title={this.props.title}
89+
description={this.props.description}
90+
retryLabel={this.props.retryLabel}
91+
className={this.props.fallbackClassName}
92+
onRetry={this.props.onRetry}
93+
/>
94+
);
95+
}
96+
97+
return this.props.children;
98+
}
99+
}
100+
101+
/**
102+
* Default fallback surface displayed by {@link NimbusErrorBoundary}. It derives
103+
* user-facing messaging from the captured error when possible and offers a
104+
* retry button that either resets the boundary or runs a custom handler.
105+
*/
106+
export function NimbusErrorBoundaryFallback(
107+
props: NimbusErrorBoundaryFallbackProps
108+
) {
109+
const { error, reset, onRetry, className } = props;
110+
const { pathname, search, hash } = useLocation();
111+
112+
const payload = safeResolveRouteErrorPayload(error);
113+
const title = props.title ?? payload?.headline ?? "Something went wrong";
114+
const description =
115+
props.description ?? payload?.description ?? "Please try again.";
116+
const retryLabel = props.retryLabel ?? "Retry";
117+
const currentLocation = `${pathname}${search}${hash}`;
118+
const rootClassName = classnames(
119+
"container container--y container--full nimbus-error-boundary",
120+
className
121+
);
122+
123+
const noopReset = useCallback(() => {}, []);
124+
const safeReset = reset ?? noopReset;
125+
126+
const handleRetry = useCallback(() => {
127+
if (onRetry) {
128+
onRetry({ error, reset: safeReset });
129+
return;
130+
}
131+
132+
window.location.assign(currentLocation);
133+
}, [currentLocation, error, onRetry, safeReset]);
134+
135+
return (
136+
<div className={rootClassName}>
137+
<p>{title}</p>
138+
{description ? (
139+
<p className="nimbus-error-boundary__description">{description}</p>
140+
) : null}
141+
<div className="nimbus-error-boundary__actions">
142+
<NewButton
143+
csVariant="accent"
144+
onClick={handleRetry}
145+
csSize="medium"
146+
rootClasses="nimbus-error-boundary__button"
147+
>
148+
{retryLabel}
149+
</NewButton>
150+
</div>
151+
</div>
152+
);
153+
}
154+
155+
/**
156+
* Generic Await error element that mirrors {@link NimbusErrorBoundaryFallback}
157+
* behaviour by surfacing the async error alongside Nimbus styling.
158+
*/
159+
export function NimbusAwaitErrorElement(props: NimbusAwaitErrorElementProps) {
160+
const error = useAsyncError();
161+
162+
return <NimbusErrorBoundaryFallback {...props} error={error} />;
163+
}
164+
165+
export function NimbusDefaultRouteErrorBoundary() {
166+
const error = useRouteError();
167+
const payload = resolveRouteErrorPayload(error);
168+
169+
return (
170+
<NimbusErrorBoundaryFallback
171+
error={error}
172+
title={payload.headline}
173+
description={payload.description}
174+
/>
175+
);
176+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./NimbusErrorBoundary";
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
ApiError,
3+
type UserFacingErrorCategory,
4+
mapApiErrorToUserFacingError,
5+
} from "@thunderstore/thunderstore-api";
6+
7+
import { defaultErrorMappings } from "./loaderMappings";
8+
import {
9+
type CreateUserFacingErrorResponseOptions,
10+
type UserFacingErrorPayload,
11+
throwUserFacingErrorResponse,
12+
throwUserFacingPayloadResponse,
13+
} from "./userFacingErrorResponse";
14+
15+
export interface LoaderErrorMapping {
16+
status: number;
17+
headline: string;
18+
description?: string;
19+
category?: UserFacingErrorCategory;
20+
includeContext?: boolean;
21+
statusOverride?: number;
22+
}
23+
24+
export interface HandleLoaderErrorOptions
25+
extends CreateUserFacingErrorResponseOptions {
26+
mappings?: LoaderErrorMapping[];
27+
}
28+
29+
/**
30+
* Normalises unknown loader errors, promoting mapped API errors to user-facing payloads
31+
* and rethrowing everything else via `throwUserFacingErrorResponse`.
32+
*/
33+
export function handleLoaderError(
34+
error: unknown,
35+
options?: HandleLoaderErrorOptions
36+
): never {
37+
if (error instanceof Response) {
38+
throw error;
39+
}
40+
41+
const resolvedOptions: HandleLoaderErrorOptions = options ?? {};
42+
const allOptions = defaultErrorMappings.concat(
43+
resolvedOptions.mappings ?? []
44+
);
45+
46+
if (error instanceof ApiError && allOptions.length) {
47+
const mapping = allOptions.findLast((candidate) => {
48+
const statuses = Array.isArray(candidate.status)
49+
? candidate.status
50+
: [candidate.status];
51+
return statuses.includes(error.response.status);
52+
});
53+
54+
if (mapping) {
55+
const base = mapApiErrorToUserFacingError(
56+
error,
57+
resolvedOptions.mapOptions
58+
);
59+
const payload: UserFacingErrorPayload = {
60+
headline: mapping.headline,
61+
description: mapping.description ?? base.description,
62+
category: mapping.category ?? base.category,
63+
status: mapping.statusOverride ?? base.status,
64+
};
65+
66+
payload.context =
67+
base.context &&
68+
(mapping.includeContext ?? resolvedOptions.includeContext ?? false)
69+
? base.context
70+
: undefined;
71+
72+
throwUserFacingPayloadResponse(payload, {
73+
statusOverride:
74+
mapping.statusOverride ?? resolvedOptions.statusOverride,
75+
});
76+
}
77+
}
78+
79+
throwUserFacingErrorResponse(error, resolvedOptions);
80+
}

0 commit comments

Comments
 (0)