Skip to content

Commit e63509b

Browse files
committed
Implement Nimbus error handling components and utilities for consistent user-facing error messages
1 parent 0e9d28f commit e63509b

File tree

13 files changed

+1146
-0
lines changed

13 files changed

+1146
-0
lines changed
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: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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/src";
5+
import { classnames } from "@thunderstore/cyberstorm/src/utils/utils";
6+
7+
import { resolveRouteErrorPayload } from "../resolveRouteErrorPayload";
8+
import "./NimbusErrorBoundary.css";
9+
10+
interface NimbusErrorBoundaryState {
11+
error: Error | null;
12+
}
13+
14+
export interface NimbusErrorRetryHandlerArgs {
15+
/** The error instance that triggered the boundary. */
16+
error: unknown;
17+
/**
18+
* Clears the captured error and re-renders the child tree. Consumers should
19+
* call this when attempting to recover without a full reload.
20+
*/
21+
reset: () => void;
22+
}
23+
24+
/**
25+
* Props accepted by {@link NimbusErrorBoundary}.
26+
*
27+
* @property {ReactNode} children React subtree guarded by the boundary.
28+
* @property {string} [title] Heading override forwarded to the fallback UI.
29+
* @property {string} [description] Description override forwarded to the fallback UI.
30+
* @property {string} [retryLabel] Custom text for the retry button; defaults to "Retry".
31+
* @property {(error: Error, info: ErrorInfo) => void} [onError] Invoked after an error is captured for telemetry.
32+
* @property {() => void} [onReset] Runs once the boundary resets so callers can clear side effects.
33+
* @property {React.ComponentType<NimbusErrorBoundaryFallbackProps>} [fallback] Custom fallback renderer; receives the captured error and reset helpers.
34+
* @property {(args: NimbusErrorRetryHandlerArgs) => void} [onRetry] Optional retry handler that replaces the default reset behaviour.
35+
* @property {string} [fallbackClassName] Additional class name applied to the fallback container.
36+
*/
37+
export interface NimbusErrorBoundaryProps {
38+
children: ReactNode;
39+
title?: string;
40+
description?: string;
41+
retryLabel?: string;
42+
onError?: (error: Error, info: ErrorInfo) => void;
43+
onReset?: () => void;
44+
fallback?: React.ComponentType<NimbusErrorBoundaryFallbackProps>;
45+
onRetry?: (args: NimbusErrorRetryHandlerArgs) => void;
46+
fallbackClassName?: string;
47+
}
48+
49+
/**
50+
* Props consumed by {@link NimbusErrorBoundaryFallback} and compatible fallbacks.
51+
*
52+
* @property {unknown} error Error instance captured by the boundary.
53+
* @property {() => void} [reset] Clears the boundary's error state when invoked.
54+
* @property {string} [title] Heading override for the rendered fallback surface.
55+
* @property {string} [description] Supplementary description shown beneath the title.
56+
* @property {string} [retryLabel] Text used for the retry button; defaults to "Retry" when omitted.
57+
* @property {(args: NimbusErrorRetryHandlerArgs) => void} [onRetry] Optional handler executed when retrying instead of the default behaviour.
58+
* @property {string} [className] Additional CSS class names appended to the fallback container.
59+
*/
60+
export interface NimbusErrorBoundaryFallbackProps {
61+
error: unknown;
62+
reset?: () => void;
63+
title?: string;
64+
description?: string;
65+
retryLabel?: string;
66+
onRetry?: (args: NimbusErrorRetryHandlerArgs) => void;
67+
className?: string;
68+
}
69+
70+
export type NimbusAwaitErrorElementProps = Pick<
71+
NimbusErrorBoundaryFallbackProps,
72+
"title" | "description" | "retryLabel" | "className" | "onRetry"
73+
>;
74+
75+
/**
76+
* NimbusErrorBoundary isolates rendering failures within a subtree and surfaces
77+
* a consistent recovery UI with an optional "Retry" affordance.
78+
*/
79+
export class NimbusErrorBoundary extends Component<
80+
NimbusErrorBoundaryProps,
81+
NimbusErrorBoundaryState
82+
> {
83+
public state: NimbusErrorBoundaryState = {
84+
error: null,
85+
};
86+
87+
public static getDerivedStateFromError(
88+
error: Error
89+
): NimbusErrorBoundaryState {
90+
return { error };
91+
}
92+
93+
public componentDidCatch(error: Error, info: ErrorInfo) {
94+
this.props.onError?.(error, info);
95+
}
96+
97+
private readonly resetBoundary = () => {
98+
this.setState({ error: null }, () => {
99+
this.props.onReset?.();
100+
});
101+
};
102+
103+
public override render() {
104+
const { error } = this.state;
105+
106+
if (error) {
107+
const FallbackComponent =
108+
this.props.fallback ?? NimbusErrorBoundaryFallback;
109+
110+
return (
111+
<FallbackComponent
112+
error={error}
113+
reset={this.resetBoundary}
114+
title={this.props.title}
115+
description={this.props.description}
116+
retryLabel={this.props.retryLabel}
117+
className={this.props.fallbackClassName}
118+
onRetry={this.props.onRetry}
119+
/>
120+
);
121+
}
122+
123+
return this.props.children;
124+
}
125+
}
126+
127+
/**
128+
* Default fallback surface displayed by {@link NimbusErrorBoundary}. It derives
129+
* user-facing messaging from the captured error when possible and offers a
130+
* retry button that either resets the boundary or runs a custom handler.
131+
*/
132+
export function NimbusErrorBoundaryFallback(
133+
props: NimbusErrorBoundaryFallbackProps
134+
) {
135+
const { error, reset, onRetry, className } = props;
136+
const { pathname, search, hash } = useLocation();
137+
138+
const payload = safeResolveRouteErrorPayload(error);
139+
const title = props.title ?? payload?.headline ?? "Something went wrong";
140+
const description =
141+
props.description ?? payload?.description ?? "Please try again.";
142+
const retryLabel = props.retryLabel ?? "Retry";
143+
const currentLocation = `${pathname}${search}${hash}`;
144+
const rootClassName = classnames(
145+
"container container--y container--full nimbus-error-boundary",
146+
className
147+
);
148+
149+
const noopReset = useCallback(() => {}, []);
150+
const safeReset = reset ?? noopReset;
151+
152+
const handleRetry = useCallback(() => {
153+
if (onRetry) {
154+
onRetry({ error, reset: safeReset });
155+
return;
156+
}
157+
158+
window.location.assign(currentLocation);
159+
}, [currentLocation, error, onRetry, safeReset]);
160+
161+
return (
162+
<div className={rootClassName}>
163+
<p>{title}</p>
164+
{description ? (
165+
<p className="nimbus-error-boundary__description">{description}</p>
166+
) : null}
167+
<div className="nimbus-error-boundary__actions">
168+
<NewButton
169+
csVariant="accent"
170+
onClick={handleRetry}
171+
csSize="medium"
172+
rootClasses="nimbus-error-boundary__button"
173+
>
174+
{retryLabel}
175+
</NewButton>
176+
</div>
177+
</div>
178+
);
179+
}
180+
181+
/**
182+
* Attempts to derive a user-facing payload from the thrown error without letting
183+
* mapper issues break the fallback UI.
184+
*/
185+
function safeResolveRouteErrorPayload(error: unknown) {
186+
try {
187+
return resolveRouteErrorPayload(error);
188+
} catch (resolutionError) {
189+
console.error("Failed to resolve route error payload", resolutionError);
190+
return null;
191+
}
192+
}
193+
194+
/**
195+
* Generic Await error element that mirrors {@link NimbusErrorBoundaryFallback}
196+
* behaviour by surfacing the async error alongside Nimbus styling.
197+
*/
198+
export function NimbusAwaitErrorElement(props: NimbusAwaitErrorElementProps) {
199+
const error = useAsyncError();
200+
201+
return <NimbusErrorBoundaryFallback {...props} error={error} />;
202+
}
203+
204+
/**
205+
* Maps loader errors to user-facing alerts for the wiki page route.
206+
*/
207+
export function NimbusDefaultRouteErrorBoundary() {
208+
const error = useRouteError();
209+
const payload = resolveRouteErrorPayload(error);
210+
211+
return (
212+
<NimbusErrorBoundaryFallback
213+
error={error}
214+
title={payload.headline}
215+
description={payload.description}
216+
/>
217+
);
218+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./NimbusErrorBoundary";
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
/**
16+
* Configuration describing how a specific HTTP status should be surfaced to the user.
17+
*/
18+
export interface LoaderErrorMapping {
19+
status: number | readonly number[];
20+
headline: string;
21+
description?: string;
22+
category?: UserFacingErrorCategory;
23+
includeContext?: boolean;
24+
statusOverride?: number;
25+
}
26+
27+
/**
28+
* Options controlling how loader errors are mapped to user-facing responses.
29+
*/
30+
export interface HandleLoaderErrorOptions
31+
extends CreateUserFacingErrorResponseOptions {
32+
mappings?: LoaderErrorMapping[];
33+
}
34+
35+
/**
36+
* Normalises unknown loader errors, promoting mapped API errors to user-facing payloads
37+
* and rethrowing everything else via `throwUserFacingErrorResponse`.
38+
*/
39+
export function handleLoaderError(
40+
error: unknown,
41+
options?: HandleLoaderErrorOptions
42+
): never {
43+
if (error instanceof Response) {
44+
throw error;
45+
}
46+
47+
const resolvedOptions: HandleLoaderErrorOptions = options ?? {};
48+
const allOptions = defaultErrorMappings.concat(
49+
resolvedOptions.mappings ?? []
50+
);
51+
52+
if (error instanceof ApiError && allOptions.length) {
53+
const mapping = allOptions.findLast((candidate) => {
54+
const statuses = Array.isArray(candidate.status)
55+
? candidate.status
56+
: [candidate.status];
57+
return statuses.includes(error.response.status);
58+
});
59+
60+
if (mapping) {
61+
const base = mapApiErrorToUserFacingError(
62+
error,
63+
resolvedOptions.mapOptions
64+
);
65+
const includeContextValue =
66+
mapping.includeContext ?? resolvedOptions.includeContext ?? false;
67+
const payload: UserFacingErrorPayload = {
68+
headline: mapping.headline,
69+
description:
70+
mapping.description !== undefined
71+
? mapping.description
72+
: base.description,
73+
category: mapping.category ?? base.category,
74+
status: mapping.statusOverride ?? error.response.status,
75+
};
76+
77+
if (includeContextValue && base.context) {
78+
payload.context = base.context;
79+
}
80+
81+
throwUserFacingPayloadResponse(payload, {
82+
statusOverride:
83+
mapping.statusOverride ?? resolvedOptions.statusOverride,
84+
});
85+
}
86+
}
87+
88+
throwUserFacingErrorResponse(error, resolvedOptions);
89+
}

0 commit comments

Comments
 (0)