From cbe037622651616f5fabcbae9e4affed233b7a9a Mon Sep 17 00:00:00 2001 From: waterWang Date: Sun, 9 Aug 2026 04:59:10 +0800 Subject: [PATCH] feat(bet-form): add error-boundary fallback UI with retry action (Closes #773) --- components/error-boundary.tsx | 15 +++++- src/components/BetFormErrorFallback.tsx | 66 +++++++++++++++++++++++ src/pages/BetForm.error-boundary.test.tsx | 50 +++++++++++++++++ src/pages/BetForm.tsx | 6 ++- 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/components/BetFormErrorFallback.tsx create mode 100644 src/pages/BetForm.error-boundary.test.tsx diff --git a/components/error-boundary.tsx b/components/error-boundary.tsx index ab05342e..5ae7045a 100644 --- a/components/error-boundary.tsx +++ b/components/error-boundary.tsx @@ -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 { @@ -50,6 +56,13 @@ export class ErrorBoundary extends Component { 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; } diff --git a/src/components/BetFormErrorFallback.tsx b/src/components/BetFormErrorFallback.tsx new file mode 100644 index 00000000..1205d6b7 --- /dev/null +++ b/src/components/BetFormErrorFallback.tsx @@ -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 ( +
+
+
+
+
+ +

+ We couldn't load the bet form +

+

+ An unexpected error occurred while rendering the bet form. Please try again. +

+ + {incidentId && ( +

+ Incident ID: {incidentId} +

+ )} + +
+ +
+ + {error?.message && ( +

+ {error.message} +

+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/BetForm.error-boundary.test.tsx b/src/pages/BetForm.error-boundary.test.tsx new file mode 100644 index 00000000..8f80933c --- /dev/null +++ b/src/pages/BetForm.error-boundary.test.tsx @@ -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( + {}} + /> + ); + 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( + + ); + fireEvent.click(screen.getByRole("button", { name: /Retry/i })); + expect(reset).toHaveBeenCalledTimes(1); + }); + + it("renders without incidentId when none is provided", () => { + render( + + ); + expect(screen.getByRole("button", { name: /Retry/i })).toBeInTheDocument(); + expect(screen.queryByText(/Incident ID:/i)).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/pages/BetForm.tsx b/src/pages/BetForm.tsx index 313897e2..b22f3f2a 100644 --- a/src/pages/BetForm.tsx +++ b/src/pages/BetForm.tsx @@ -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"; @@ -41,7 +43,7 @@ export default function BetForm({ campaignActive = true }: BetFormProps = {}) { {!campaignActive ? ( ) : ( - <> + @@ -95,7 +97,7 @@ export default function BetForm({ campaignActive = true }: BetFormProps = {}) { )} - + )} );