Skip to content

Commit 2d3f2a3

Browse files
committed
Implement Nimbus error handling components and utilities for consistent user-facing error messages
1 parent 7488263 commit 2d3f2a3

File tree

11 files changed

+1138
-0
lines changed

11 files changed

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

0 commit comments

Comments
 (0)