feat(FN-1286): add ErrorBoundary component with page-level, modal, and root-level wrapping
- Create ErrorBoundary and ModalErrorBoundary components with recovery actions (retry, reload) - Add error boundary CSS styles with fallback UI styling - Wrap main dashboard pages (Board, List, Settings, Agents) with PageErrorBoundary in App.tsx - Wrap all key modals (task detail, planning, subtask breakdown, mission) with ModalErrorBoundary in AppModals.tsx - Wrap entire App with RootErrorBoundary in main.tsx as final safety net - Add 11 comprehensive ErrorBoundary tests covering all rendering and error scenarios
This commit is contained in:
@@ -7,6 +7,7 @@ import { ProjectOverview } from "./components/ProjectOverview";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { MissionManager } from "./components/MissionManager";
|
||||
import { NodesView } from "./components/NodesView";
|
||||
import { PageErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { AppModals } from "./components/AppModals";
|
||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
@@ -244,107 +245,121 @@ function AppInner() {
|
||||
<div className="nodes-management-overlay__header">
|
||||
<button className="btn btn-sm" onClick={() => setNodesOpen(false)}>Close Nodes</button>
|
||||
</div>
|
||||
<NodesView addToast={addToast} />
|
||||
<PageErrorBoundary>
|
||||
<NodesView addToast={addToast} />
|
||||
</PageErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === "overview") {
|
||||
return (
|
||||
<ProjectOverview
|
||||
projects={projects}
|
||||
loading={projectsLoading}
|
||||
onSelectProject={handleSelectProject}
|
||||
onAddProject={handleAddProject}
|
||||
onPauseProject={handlePauseProject}
|
||||
onResumeProject={handleResumeProject}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
nodes={nodes}
|
||||
/>
|
||||
<PageErrorBoundary>
|
||||
<ProjectOverview
|
||||
projects={projects}
|
||||
loading={projectsLoading}
|
||||
onSelectProject={handleSelectProject}
|
||||
onAddProject={handleAddProject}
|
||||
onPauseProject={handlePauseProject}
|
||||
onResumeProject={handleResumeProject}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
nodes={nodes}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Project view
|
||||
if (taskView === "missions") {
|
||||
return (
|
||||
<MissionManager
|
||||
isInline={true}
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
setMissionTargetId(undefined);
|
||||
setMissionResumeSessionId(undefined);
|
||||
handleChangeTaskView("board");
|
||||
}}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
onSelectTask={(taskId) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) modalManager.openDetailTask(task as TaskDetail);
|
||||
}}
|
||||
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
|
||||
resumeSessionId={missionResumeSessionId}
|
||||
targetMissionId={missionTargetId}
|
||||
/>
|
||||
<PageErrorBoundary>
|
||||
<MissionManager
|
||||
isInline={true}
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
setMissionTargetId(undefined);
|
||||
setMissionResumeSessionId(undefined);
|
||||
handleChangeTaskView("board");
|
||||
}}
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
onSelectTask={(taskId) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) modalManager.openDetailTask(task as TaskDetail);
|
||||
}}
|
||||
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
|
||||
resumeSessionId={missionResumeSessionId}
|
||||
targetMissionId={missionTargetId}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "agents") {
|
||||
return <AgentsView addToast={addToast} projectId={currentProject?.id} />;
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<AgentsView addToast={addToast} projectId={currentProject?.id} />
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "board") {
|
||||
return (
|
||||
<Board
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
addToast={addToast}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
onNewTask={modalManager.openNewTask}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={toggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={updateTask}
|
||||
onArchiveTask={archiveTask}
|
||||
onUnarchiveTask={unarchiveTask}
|
||||
onArchiveAllDone={archiveAllDone}
|
||||
searchQuery={searchQuery}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={handleOpenDetailWithTab}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={handleOpenMission}
|
||||
/>
|
||||
<PageErrorBoundary>
|
||||
<Board
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
addToast={addToast}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
onNewTask={modalManager.openNewTask}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={toggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={updateTask}
|
||||
onArchiveTask={archiveTask}
|
||||
onUnarchiveTask={unarchiveTask}
|
||||
onArchiveAllDone={archiveAllDone}
|
||||
searchQuery={searchQuery}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={handleOpenDetailWithTab}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={handleOpenMission}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onNewTask={modalManager.openNewTask}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
|
||||
availableModels={availableModels}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
/>
|
||||
<PageErrorBoundary>
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onNewTask={modalManager.openNewTask}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
|
||||
availableModels={availableModels}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UseProjectActionsResult } from "../hooks/useProjectActions";
|
||||
import type { ModalManager } from "../hooks/useModalManager";
|
||||
import type { UseTaskHandlersResult } from "../hooks/useTaskHandlers";
|
||||
import type { Toast, ToastType } from "../hooks/useToast";
|
||||
import { ModalErrorBoundary } from "./ErrorBoundary";
|
||||
import { TaskDetailModal } from "./TaskDetailModal";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { GitHubImportModal } from "./GitHubImportModal";
|
||||
@@ -72,35 +73,39 @@ export function AppModals({
|
||||
return (
|
||||
<>
|
||||
{modalManager.detailTask && (
|
||||
<TaskDetailModal
|
||||
task={modalManager.detailTask}
|
||||
projectId={projectId}
|
||||
tasks={tasks}
|
||||
onClose={deepLink.handleDetailClose}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
onMoveTask={taskOperations.moveTask}
|
||||
onDeleteTask={taskOperations.deleteTask}
|
||||
onMergeTask={taskOperations.mergeTask}
|
||||
onRetryTask={taskOperations.retryTask}
|
||||
onDuplicateTask={taskOperations.duplicateTask}
|
||||
onTaskUpdated={modalManager.updateDetailTask}
|
||||
addToast={addToast}
|
||||
githubTokenConfigured={settings.githubTokenConfigured}
|
||||
initialTab={modalManager.detailTaskInitialTab}
|
||||
/>
|
||||
<ModalErrorBoundary>
|
||||
<TaskDetailModal
|
||||
task={modalManager.detailTask}
|
||||
projectId={projectId}
|
||||
tasks={tasks}
|
||||
onClose={deepLink.handleDetailClose}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
onMoveTask={taskOperations.moveTask}
|
||||
onDeleteTask={taskOperations.deleteTask}
|
||||
onMergeTask={taskOperations.mergeTask}
|
||||
onRetryTask={taskOperations.retryTask}
|
||||
onDuplicateTask={taskOperations.duplicateTask}
|
||||
onTaskUpdated={modalManager.updateDetailTask}
|
||||
addToast={addToast}
|
||||
githubTokenConfigured={settings.githubTokenConfigured}
|
||||
initialTab={modalManager.detailTaskInitialTab}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
)}
|
||||
|
||||
{modalManager.settingsOpen && (
|
||||
<SettingsModal
|
||||
onClose={modalManager.closeSettings}
|
||||
addToast={addToast}
|
||||
initialSection={modalManager.settingsInitialSection}
|
||||
projectId={projectId}
|
||||
themeMode={settings.themeMode}
|
||||
colorTheme={settings.colorTheme}
|
||||
onThemeModeChange={settings.setThemeMode}
|
||||
onColorThemeChange={settings.setColorTheme}
|
||||
/>
|
||||
<ModalErrorBoundary>
|
||||
<SettingsModal
|
||||
onClose={modalManager.closeSettings}
|
||||
addToast={addToast}
|
||||
initialSection={modalManager.settingsInitialSection}
|
||||
projectId={projectId}
|
||||
themeMode={settings.themeMode}
|
||||
colorTheme={settings.colorTheme}
|
||||
onThemeModeChange={settings.setThemeMode}
|
||||
onColorThemeChange={settings.setColorTheme}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
)}
|
||||
|
||||
<GitHubImportModal
|
||||
@@ -110,25 +115,29 @@ export function AppModals({
|
||||
tasks={tasks}
|
||||
/>
|
||||
|
||||
<PlanningModeModal
|
||||
isOpen={modalManager.isPlanningOpen}
|
||||
onClose={modalManager.closePlanning}
|
||||
onTaskCreated={taskHandlers.handlePlanningTaskCreated}
|
||||
onTasksCreated={taskHandlers.handlePlanningTasksCreated}
|
||||
tasks={tasks}
|
||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||
projectId={projectId}
|
||||
resumeSessionId={modalManager.planningResumeSessionId}
|
||||
/>
|
||||
<ModalErrorBoundary>
|
||||
<PlanningModeModal
|
||||
isOpen={modalManager.isPlanningOpen}
|
||||
onClose={modalManager.closePlanning}
|
||||
onTaskCreated={taskHandlers.handlePlanningTaskCreated}
|
||||
onTasksCreated={taskHandlers.handlePlanningTasksCreated}
|
||||
tasks={tasks}
|
||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||
projectId={projectId}
|
||||
resumeSessionId={modalManager.planningResumeSessionId}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
<SubtaskBreakdownModal
|
||||
isOpen={modalManager.isSubtaskOpen}
|
||||
onClose={modalManager.closeSubtask}
|
||||
initialDescription={modalManager.subtaskInitialDescription ?? ""}
|
||||
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
|
||||
projectId={projectId}
|
||||
resumeSessionId={modalManager.subtaskResumeSessionId}
|
||||
/>
|
||||
<ModalErrorBoundary>
|
||||
<SubtaskBreakdownModal
|
||||
isOpen={modalManager.isSubtaskOpen}
|
||||
onClose={modalManager.closeSubtask}
|
||||
initialDescription={modalManager.subtaskInitialDescription ?? ""}
|
||||
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
|
||||
projectId={projectId}
|
||||
resumeSessionId={modalManager.subtaskResumeSessionId}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
<TerminalModal
|
||||
isOpen={modalManager.terminalOpen}
|
||||
@@ -167,16 +176,18 @@ export function AppModals({
|
||||
/>
|
||||
)}
|
||||
|
||||
<NewTaskModal
|
||||
isOpen={modalManager.newTaskModalOpen}
|
||||
onClose={modalManager.closeNewTask}
|
||||
tasks={tasks}
|
||||
onCreateTask={taskHandlers.handleModalCreate}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
|
||||
/>
|
||||
<ModalErrorBoundary>
|
||||
<NewTaskModal
|
||||
isOpen={modalManager.newTaskModalOpen}
|
||||
onClose={modalManager.closeNewTask}
|
||||
tasks={tasks}
|
||||
onCreateTask={taskHandlers.handleModalCreate}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
<ActivityLogModal
|
||||
isOpen={modalManager.activityLogOpen}
|
||||
@@ -193,19 +204,23 @@ export function AppModals({
|
||||
}}
|
||||
/>
|
||||
|
||||
<GitManagerModal
|
||||
isOpen={modalManager.gitManagerOpen}
|
||||
onClose={modalManager.closeGitManager}
|
||||
tasks={tasks}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<ModalErrorBoundary>
|
||||
<GitManagerModal
|
||||
isOpen={modalManager.gitManagerOpen}
|
||||
onClose={modalManager.closeGitManager}
|
||||
tasks={tasks}
|
||||
addToast={addToast}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
<WorkflowStepManager
|
||||
isOpen={modalManager.workflowStepsOpen}
|
||||
onClose={modalManager.closeWorkflowSteps}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<ModalErrorBoundary>
|
||||
<WorkflowStepManager
|
||||
isOpen={modalManager.workflowStepsOpen}
|
||||
onClose={modalManager.closeWorkflowSteps}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
<AgentListModal
|
||||
isOpen={modalManager.agentsOpen}
|
||||
|
||||
94
packages/dashboard/app/components/ErrorBoundary.tsx
Normal file
94
packages/dashboard/app/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
level?: "page" | "modal" | "root";
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("[ErrorBoundary]", error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
resetErrorBoundary = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
const level = this.props.level ?? "page";
|
||||
const isModal = level === "modal";
|
||||
const title = isModal
|
||||
? "This section encountered an error"
|
||||
: "Something went wrong";
|
||||
|
||||
return (
|
||||
<div className={`error-boundary error-boundary--${level}`}>
|
||||
<div className="error-boundary__icon">
|
||||
<AlertTriangle size={40} />
|
||||
</div>
|
||||
<div className="error-boundary__title">{title}</div>
|
||||
{this.state.error && (
|
||||
<pre className="error-boundary__message">{this.state.error.message}</pre>
|
||||
)}
|
||||
<div className="error-boundary__actions">
|
||||
<button className="btn btn-primary" onClick={this.resetErrorBoundary}>
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn" onClick={() => window.location.reload()}>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function PageErrorBoundary({ children, onError }: { children: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void }) {
|
||||
return (
|
||||
<ErrorBoundary level="page" onError={onError}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalErrorBoundary({ children, onError }: { children: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void }) {
|
||||
return (
|
||||
<ErrorBoundary level="modal" onError={onError}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function RootErrorBoundary({ children, onError }: { children: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void }) {
|
||||
return (
|
||||
<ErrorBoundary level="root" onError={onError}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { act } from "react";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
PageErrorBoundary,
|
||||
ModalErrorBoundary,
|
||||
RootErrorBoundary,
|
||||
} from "../ErrorBoundary";
|
||||
|
||||
// Suppress console.error noise from React error boundary logging
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ---- Throwing child helper ----
|
||||
|
||||
function ThrowingChild({ shouldThrow }: { shouldThrow?: boolean }) {
|
||||
if (shouldThrow !== false) {
|
||||
throw new Error("Test render error");
|
||||
}
|
||||
return <div data-testid="child-ok">OK</div>;
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
it("catches child render error and shows default fallback", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test render error")).toBeInTheDocument();
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reload page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onError callback", () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ componentStack: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders custom fallback when provided", () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div data-testid="custom">Custom</div>}>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("custom")).toBeInTheDocument();
|
||||
expect(screen.getByText("Custom")).toBeInTheDocument();
|
||||
// Default fallback should NOT appear
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resetErrorBoundary recovers from error", () => {
|
||||
let shouldThrow = true;
|
||||
|
||||
function ConditionalChild() {
|
||||
if (shouldThrow) {
|
||||
throw new Error("Test render error");
|
||||
}
|
||||
return <div data-testid="child-ok">OK</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ConditionalChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// Error fallback should be visible
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("child-ok")).not.toBeInTheDocument();
|
||||
|
||||
// Fix the error source before retrying
|
||||
shouldThrow = false;
|
||||
|
||||
// Click Retry button
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Retry"));
|
||||
});
|
||||
|
||||
// Child should now render successfully
|
||||
expect(screen.getByTestId("child-ok")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('level prop applies correct CSS class for "page"', () => {
|
||||
render(
|
||||
<ErrorBoundary level="page">
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const container = screen.getByText("Something went wrong").closest(".error-boundary");
|
||||
expect(container).toHaveClass("error-boundary--page");
|
||||
});
|
||||
|
||||
it('level prop applies correct CSS class for "modal"', () => {
|
||||
render(
|
||||
<ErrorBoundary level="modal">
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const container = screen.getByText("This section encountered an error").closest(".error-boundary");
|
||||
expect(container).toHaveClass("error-boundary--modal");
|
||||
});
|
||||
|
||||
it('level prop applies correct CSS class for "root"', () => {
|
||||
render(
|
||||
<ErrorBoundary level="root">
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const container = screen.getByText("Something went wrong").closest(".error-boundary");
|
||||
expect(container).toHaveClass("error-boundary--root");
|
||||
});
|
||||
|
||||
it("renders children normally when no error occurs", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild shouldThrow={false} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-ok")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageErrorBoundary", () => {
|
||||
it("renders with page level", () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
const container = screen.getByText("Something went wrong").closest(".error-boundary");
|
||||
expect(container).toHaveClass("error-boundary--page");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModalErrorBoundary", () => {
|
||||
it("renders with modal level", () => {
|
||||
render(
|
||||
<ModalErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ModalErrorBoundary>,
|
||||
);
|
||||
|
||||
const container = screen.getByText("This section encountered an error").closest(".error-boundary");
|
||||
expect(container).toHaveClass("error-boundary--modal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RootErrorBoundary", () => {
|
||||
it("renders with root level", () => {
|
||||
render(
|
||||
<RootErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</RootErrorBoundary>,
|
||||
);
|
||||
|
||||
const container = screen.getByText("Something went wrong").closest(".error-boundary");
|
||||
expect(container).toHaveClass("error-boundary--root");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RootErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<RootErrorBoundary>
|
||||
<App />
|
||||
</RootErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
|
||||
@@ -26690,3 +26690,75 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Error Boundary === */
|
||||
|
||||
.error-boundary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-xl);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-error);
|
||||
max-width: 480px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error-boundary--root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-boundary--page {
|
||||
padding: var(--space-2xl) var(--space-xl);
|
||||
}
|
||||
|
||||
.error-boundary--modal {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.error-boundary__icon {
|
||||
color: var(--color-error);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.error-boundary__icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.error-boundary__title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.error-boundary__message {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
margin: var(--space-md) 0;
|
||||
overflow-x: auto;
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.error-boundary__actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user