Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
import { ErrorRecoveryScreen } from "@/components/error/ErrorRecoveryScreen";

interface FallbackRenderProps {
error: Error;
incidentId: string | null;
resetErrorBoundary: () => void;
}

interface Props {
children: ReactNode;
fallback?: ReactNode;
fallback?: ReactNode | ((props: FallbackRenderProps) => ReactNode);
}

interface State {
Expand Down Expand Up @@ -50,6 +56,13 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
if (this.props.fallback) {
if (typeof this.props.fallback === "function") {
return (this.props.fallback as (props: FallbackRenderProps) => ReactNode)({
error: this.state.error!,
incidentId: this.state.incidentId,
resetErrorBoundary: this.handleReset,
});
}
return this.props.fallback;
}

Expand Down
66 changes: 66 additions & 0 deletions src/components/BetFormErrorFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import React from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCw } from "lucide-react";

interface BetFormErrorFallbackProps {
error: Error;
incidentId: string | null;
resetErrorBoundary: () => void;
}

/**
* BetForm error-boundary fallback UI.
*
* Renders an attractive, card-styled fallback with a retry action when BetForm
* throws during render. Respects design tokens and dark mode, and exposes the
* incident id + error message for transparency without leaking stack traces.
*/
export function BetFormErrorFallback({
error,
incidentId,
resetErrorBoundary,
}: BetFormErrorFallbackProps) {
return (
<div className="mx-auto max-w-md p-4 sm:p-6 lg:p-8">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm">
<div className="flex flex-col items-center p-8 text-center">
<div
className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10 dark:bg-destructive/20"
role="img"
aria-label="Error"
>
<AlertTriangle className="h-7 w-7 text-destructive" aria-hidden="true" />
</div>

<h2 className="mb-2 text-lg font-semibold text-foreground">
We couldn't load the bet form
</h2>
<p className="mb-5 max-w-sm text-sm text-muted-foreground">
An unexpected error occurred while rendering the bet form. Please try again.
</p>

{incidentId && (
<p className="mb-4 rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground">
Incident ID: {incidentId}
</p>
)}

<div className="flex flex-col gap-3 sm:flex-row">
<Button onClick={resetErrorBoundary} size="lg" className="gap-2">
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Retry
</Button>
</div>

{error?.message && (
<p className="mt-5 hidden text-xs text-muted-foreground" data-testid="bet-form-error-msg">
{error.message}
</p>
)}
</div>
</div>
</div>
);
}
50 changes: 50 additions & 0 deletions src/pages/BetForm.error-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import BetForm from "../BetForm";
import { BetFormErrorFallback } from "../components/BetFormErrorFallback";

describe("BetForm error boundary fallback (#773)", () => {
it("renders the custom fallback when a child throws", () => {
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<BetFormErrorFallback
error={new Error("boom")}
incidentId="test-123"
resetErrorBoundary={() => {}}
/>
);
expect(screen.getByText(/couldn't load the bet form/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument();
expect(screen.getByText(/Incident ID: test-123/i)).toBeInTheDocument();
expect(screen.getByTestId("bet-form-error-msg")).toHaveTextContent("boom");
} finally {
spy.mockRestore();
}
});

it("calls resetErrorBoundary when Retry is clicked", () => {
const reset = jest.fn();
render(
<BetFormErrorFallback
error={new Error("boom")}
incidentId={null}
resetErrorBoundary={reset}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Retry/i }));
expect(reset).toHaveBeenCalledTimes(1);
});

it("renders without incidentId when none is provided", () => {
render(
<BetFormErrorFallback
error={new Error("test error")}
incidentId={null}
resetErrorBoundary={jest.fn()}
/>
);
expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument();
expect(screen.queryByText(/Incident ID:/i)).not.toBeInTheDocument();
});
});
6 changes: 4 additions & 2 deletions src/pages/BetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { StellarWaveEmptyState } from "../components/EmptyState";
import { ErrorBoundary } from "../../components/error-boundary";
import { BetFormErrorFallback } from "../components/BetFormErrorFallback";

type FormState = "idle" | "submitting" | "success" | "error";

Expand Down Expand Up @@ -41,7 +43,7 @@ export default function BetForm({ campaignActive = true }: BetFormProps = {}) {
{!campaignActive ? (
<StellarWaveEmptyState />
) : (
<>
<ErrorBoundary fallback={BetFormErrorFallback}>
<LiveRegion message={announcement} />

<Card className="overflow-hidden border-border/60 bg-card/80 shadow-sm">
Expand Down Expand Up @@ -95,7 +97,7 @@ export default function BetForm({ campaignActive = true }: BetFormProps = {}) {
)}
</CardContent>
</Card>
</>
</ErrorBoundary>
)}
</div>
);
Expand Down