Skip to content

Commit

Permalink
feat: add recoverability to errors, more implementation examples
Browse files Browse the repository at this point in the history
  • Loading branch information
dominik-stumpf committed Dec 11, 2024
1 parent c7473b4 commit b67e05e
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 26 deletions.
7 changes: 2 additions & 5 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,21 +139,18 @@ const nextConfig = {
},
}

module.exports = nextConfig


// Injected content via Sentry wizard below

const { withSentryConfig } = require("@sentry/nextjs");

module.exports = withSentryConfig(
module.exports,
nextConfig,
{
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options

org: "zgen",
project: "javascript-nextjs",
project: "guildxyz",

// Only print logs for uploading source maps in CI
silent: !process.env.CI,
Expand Down
16 changes: 15 additions & 1 deletion src/app/(dashboard)/[guildUrlName]/[pageUrlName]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { ScrollArea } from "@/components/ui/ScrollArea";
import { Skeleton } from "@/components/ui/Skeleton";
import { CustomError, FetchError } from "@/lib/error";
import { rewardBatchOptions, roleBatchOptions } from "@/lib/options";
import type { Schemas } from "@guildxyz/types";
import { Lock } from "@phosphor-icons/react/dist/ssr";
Expand Down Expand Up @@ -44,6 +45,14 @@ const GuildPage = () => {
};

const RoleCard = ({ role }: { role: Schemas["Role"] }) => {
const blacklistedRoleName = "Member";
if (role.name === blacklistedRoleName) {
throw new FetchError({
recoverable: true,
message: `Failed to show ${role.name} role`,
cause: FetchError.expected`${{ roleName: role.name }} to not match ${{ blacklistedRoleName }}`,
});
}
const { data: rewards } = useSuspenseQuery(
rewardBatchOptions({ roleId: role.id }),
);
Expand All @@ -68,7 +77,9 @@ const RoleCard = ({ role }: { role: Schemas["Role"] }) => {
<ScrollArea className="mt-8 h-64 rounded-lg border pr-3">
<div className="flex flex-col gap-4">
{rewards.map((reward) => (
<Reward reward={reward} key={reward.id} />
<ErrorBoundary FallbackComponent={GenericError} key={reward.id}>
<Reward reward={reward} />
</ErrorBoundary>
))}
</div>
</ScrollArea>
Expand Down Expand Up @@ -101,6 +112,9 @@ const RoleCard = ({ role }: { role: Schemas["Role"] }) => {
};

const Reward = ({ reward }: { reward: Schemas["Reward"] }) => {
if (reward.name === "Admin - update") {
throw new CustomError();
}
return (
<div className="border-b p-4 last:border-b-0">
<div className="mb-2 font-medium">{reward.name}</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ErrorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const ErrorPage = ({
className="font-black text-[clamp(128px,32vw,360px)] text-foreground leading-none tracking-tight opacity-20"
aria-hidden
>
{errorCode}
{errorCode.slice(0, 3)}
</div>
<div className="absolute inset-0 bg-gradient-to-t from-background" />
</div>
Expand Down
44 changes: 30 additions & 14 deletions src/components/GenericError.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
"use client";

import type { CustomError } from "@/lib/error";
import { type CustomError, ValidationError } from "@/lib/error";
import { useErrorBoundary } from "react-error-boundary";
import type { Jsonify } from "type-fest";
import Markdown from "react-markdown";
import { ZodError } from "zod";
import { Button } from "./ui/Button";
import { Card } from "./ui/Card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./ui/Collapsible";

export const GenericError = ({ error }: { error: Jsonify<CustomError> }) => {
export const GenericError = ({ error }: { error: CustomError | ZodError }) => {
const { resetBoundary } = useErrorBoundary();
const message =
error instanceof ZodError ? error.issues.at(0)?.message : error.message;
const convergedError =
error instanceof ZodError ? ValidationError.fromZodError(error) : error;

return (
<Card role="alert" className="size-full bg-red-400/20 p-6 text-red-200">
<div className="max-w-prose space-y-4">
<h3 className="font-bold">Something went wrong on our side</h3>
<p className="leading-relaxed">{message}</p>
<Button
onClick={resetBoundary}
variant="subtle"
colorScheme="destructive"
>
Try again
</Button>
<h3 className="font-bold">{convergedError.display}</h3>
{convergedError.cause && (
<Collapsible>
<CollapsibleTrigger className="mb-4 underline decoration-dashed underline-offset-4">
Read more about what went wrong
</CollapsibleTrigger>
<CollapsibleContent>
<Markdown>{convergedError.cause}</Markdown>
</CollapsibleContent>
</Collapsible>
)}
{error.recoverable && (
<Button
onClick={resetBoundary}
variant="subtle"
colorScheme="destructive"
>
Try again
</Button>
)}
</div>
</Card>
);
Expand Down
37 changes: 32 additions & 5 deletions src/lib/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PartialDeep, Primitive } from "type-fest";
import type { ZodError } from "zod";

//export const promptRetryMessages = [
// "Please try refreshing or contact support if the issue persists.",
Expand All @@ -11,7 +12,7 @@ import type { PartialDeep, Primitive } from "type-fest";
*/
export type Either<Data, _ extends Error> = Data;

type ReasonParts = [TemplateStringsArray, ...Record<string, Primitive>[]];
type ReasonParts = [ArrayLike<string>, ...Record<string, Primitive>[]];

/**
* Serializable `Error` object custom errors derive from.
Expand All @@ -25,7 +26,9 @@ export class CustomError extends Error {
public override get message() {
return [this.display, this.cause].filter(Boolean).join("\n\n");
}
private readonly causeRaw: ReasonParts;
public causeRaw: ReasonParts;

public recoverable: boolean;

public override get cause() {
const interpolated = this.interpolateErrorCause();
Expand All @@ -37,14 +40,14 @@ export class CustomError extends Error {
return args;
}

private interpolateErrorCause(delimiter = " and ") {
private interpolateErrorCause(delimiter = ", ") {
const [templateStringArray, ...props] = this.causeRaw;
return templateStringArray
return Array.from(templateStringArray)
.reduce<Primitive[]>((acc, val, i) => {
acc.push(
val,
...Object.entries(props.at(i) ?? {})
.map(([key, value]) => `${key} \`${String(value)}\``)
.map(([key, value]) => `**${key}** \`${String(value)}\``)
.join(delimiter),
);
return acc;
Expand All @@ -56,20 +59,23 @@ export class CustomError extends Error {
props?: PartialDeep<{
message: string;
cause: ReasonParts;
recoverable: boolean;
}>,
) {
super();

this.name = this.constructor.name;
this.display = props?.message || this.defaultDisplay;
this.causeRaw = props?.cause ?? CustomError.expected``;
this.recoverable = props?.recoverable || false;
}

public toJSON() {
return {
name: this.name,
message: this.message,
cause: this.cause,
display: this.display,
};
}

Expand Down Expand Up @@ -99,6 +105,27 @@ export class ValidationError extends CustomError {
protected override get defaultDisplay() {
return "There are issues with the provided data.";
}

public static fromZodError(error: ZodError): ValidationError {
const result = new ValidationError();
const parsedIssues = error.issues.flatMap((issue) => {
const path = issue.path.join(" -> ");
const { message, code } = issue;
return Object.entries({ code, path, message }).map((entry) =>
Object.fromEntries([entry]),
);
});

result.causeRaw = [
[
"Zod validation to pass, but failed at: \n",
...parsedIssues.slice(2).flatMap(() => [" occured at ", " with ", "."]),
],
...parsedIssues,
];

return result;
}
}

/**
Expand Down

0 comments on commit b67e05e

Please sign in to comment.