fix: give Calendar, Contacts, Files and Settings their own error boundary and show one error fallback with Try again everywhere

This commit is contained in:
Linus Rath
2026-10-28 03:56:04 +01:00
parent 9ee767f576
commit 5d013f7669
9 changed files with 202 additions and 123 deletions
@@ -1,5 +1,6 @@
import { CalendarApp } from "@/components/calendar/calendar-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Calendar permalinks (#733):
// /calendar the stored view, today
@@ -17,5 +18,9 @@ export default async function CalendarRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <CalendarApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary>
<CalendarApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
@@ -1,5 +1,6 @@
import { ContactsApp } from "@/components/contacts/contacts-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Contact permalinks (#733): /contacts/<contactId>[/edit], plus /contacts/new
// for the "add this sender" flow. The older query form (?contactId=, ?addEmail=)
@@ -15,5 +16,9 @@ export default async function ContactsRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <ContactsApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary>
<ContactsApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
+9 -27
View File
@@ -2,9 +2,7 @@
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle, RefreshCw, Home } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
import { ErrorState } from "@/components/error/error-fallbacks";
/**
* Route-level error boundary for locale pages.
@@ -18,35 +16,19 @@ export default function LocaleError({
reset: () => void;
}) {
const t = useTranslations("errors");
const router = useRouter();
useEffect(() => {
console.error("Route error:", error);
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{t("page_error_title")}
</h2>
<p className="text-muted-foreground mb-6">
{t("page_error_description")}
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push('/')}>
<Home className="w-4 h-4 me-2" />
{t("go_home")}
</Button>
<Button onClick={reset}>
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
</div>
</div>
<ErrorState
className="min-h-screen"
title={t("page_error_title")}
description={t("page_error_description")}
onRetry={reset}
retryLabel={t("try_again")}
homeLabel={t("go_home")}
/>
);
}
@@ -1,5 +1,6 @@
import { FilesApp } from "@/components/files/files-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Files permalinks (#733): /files/<folder>/<subfolder>, one path segment per
// level, with ?preview=<name> to open a file straight into the preview modal.
@@ -14,5 +15,9 @@ export default async function FilesRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <FilesApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary>
<FilesApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
@@ -1,5 +1,6 @@
import { SettingsApp } from "@/components/settings/settings-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Settings permalinks (#733): /settings/<tabId>. Without a tab the page falls
// back to the last tab the user had open, as before.
@@ -14,5 +15,9 @@ export default async function SettingsRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <SettingsApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary kind="settings">
<SettingsApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
@@ -0,0 +1,56 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// One error fallback everywhere: the same "Try again", a way back to the
// inbox outside mail, and each app has a boundary of its own that keeps the
// navigation on screen.
vi.mock('@/components/layout/navigation-rail', () => ({
NavigationRail: ({ orientation }: { orientation?: string }) => <nav aria-label={`rail-${orientation ?? 'vertical'}`} />,
}));
const { AppErrorBoundary } = await import('../app-error-boundary');
const { ErrorBoundary } = await import('../error-boundary');
const { EmailListErrorFallback, SidebarErrorFallback, SettingsErrorFallback } = await import('../error-fallbacks');
let broken = true;
function Flaky() {
if (broken) throw new Error('boom');
return <p>calendar works</p>;
}
beforeEach(() => {
broken = true;
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
describe('app error boundary', () => {
it('shows the fallback next to the navigation and recovers on Try again', () => {
render(<AppErrorBoundary><Flaky /></AppErrorBoundary>);
expect(screen.getByRole('alert')).toHaveTextContent('page_error_title');
expect(screen.getByRole('navigation', { name: 'rail-vertical' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'go_home' })).toBeInTheDocument();
broken = false;
fireEvent.click(screen.getByRole('button', { name: 'try_again' }));
expect(screen.getByText('calendar works')).toBeInTheDocument();
});
});
describe('section fallbacks', () => {
it.each([
['message list', EmailListErrorFallback],
['folder sidebar', SidebarErrorFallback],
['settings', SettingsErrorFallback],
])('the %s offers the same "Try again"', (_name, fallback) => {
render(<ErrorBoundary fallback={fallback}><Flaky /></ErrorBoundary>);
expect(screen.getByRole('button', { name: 'try_again' })).toBeInTheDocument();
});
it('inside mail, does not offer to go to the inbox it is already in', () => {
render(<ErrorBoundary fallback={EmailListErrorFallback}><Flaky /></ErrorBoundary>);
expect(screen.queryByRole('link', { name: 'go_home' })).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { useIsMobile } from "@/hooks/use-media-query";
import { ErrorBoundary, type FallbackProps } from "./error-boundary";
import { PageErrorFallback, SettingsErrorFallback } from "./error-fallbacks";
function AppErrorLayout({ kind, ...props }: FallbackProps & { kind: "page" | "settings" }) {
const isMobile = useIsMobile();
const Fallback = kind === "settings" ? SettingsErrorFallback : PageErrorFallback;
// The navigation stays, so one app crashing doesn't strand the user in it.
return (
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0 border-e border-border">
<NavigationRail collapsed />
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<Fallback {...props} />
{isMobile && <NavigationRail orientation="horizontal" />}
</div>
</div>
);
}
/**
* Wraps one app (Calendar, Contacts, Files, Settings): a crash in it shows
* the shared error fallback next to the navigation, instead of the route
* error page in place of everything.
*/
export function AppErrorBoundary({ children, kind = "page" }: { children: ReactNode; kind?: "page" | "settings" }) {
return (
<ErrorBoundary fallback={(props) => <AppErrorLayout kind={kind} {...props} />}>
{children}
</ErrorBoundary>
);
}
+72 -92
View File
@@ -1,127 +1,107 @@
"use client";
import { AlertCircle, RefreshCw, Inbox, Mail, Settings, FolderOpen } from "@/components/icons";
import { AlertCircle, RefreshCw } from "@/components/icons";
import type { AppIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { getPathPrefix } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import type { FallbackProps } from "./error-boundary";
export interface ErrorStateProps {
title: string;
description?: string;
/** Puts the part back together: re-renders it, or reloads the route. */
onRetry: () => void;
retryLabel: string;
/** Offers a way out to the inbox; left out inside mail itself. */
homeLabel?: string;
icon?: AppIcon;
className?: string;
}
/**
* Full-page error fallback for route-level errors.
* The one error fallback: icon, title, a line of text, "Try again", and "Go
* to inbox" where the inbox is somewhere else. Every error boundary draws
* this, at whatever size its pane has.
*/
export function PageErrorFallback({ error: _error, resetError, t }: FallbackProps) {
export function ErrorState({ title, description, onRetry, retryLabel, homeLabel, icon: Icon = AlertCircle, className }: ErrorStateProps) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{t("page_error_title")}
</h2>
<p className="text-muted-foreground mb-6">
{t("page_error_description")}
</p>
<Button onClick={resetError}>
<div role="alert" className={cn("flex flex-1 h-full flex-col items-center justify-center p-8 text-center bg-background", className)}>
<div className="w-12 h-12 mb-4 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<Icon className="w-6 h-6 text-red-600 dark:text-red-400" aria-hidden="true" />
</div>
<h2 className="text-lg font-semibold text-foreground mb-2">{title}</h2>
{description && <p className="text-sm text-muted-foreground mb-6 max-w-md">{description}</p>}
<div className={cn("flex flex-wrap gap-3 justify-center", !description && "mt-4")}>
{homeLabel && (
// A full load: whatever broke starts over.
<a
href={`${getPathPrefix()}/`}
className="inline-flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
>
{homeLabel}
</a>
)}
<Button size="sm" onClick={onRetry}>
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
{retryLabel}
</Button>
</div>
</div>
);
}
/**
* Sidebar error fallback - matches sidebar width (256px).
*/
/** Full-page error fallback, also used around each app (AppErrorBoundary). */
export function PageErrorFallback({ resetError, t }: FallbackProps) {
return (
<ErrorState
title={t("page_error_title")}
description={t("page_error_description")}
onRetry={resetError}
retryLabel={t("try_again")}
homeLabel={t("go_home")}
/>
);
}
/** Mail's folder sidebar (256px wide). */
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-64 h-full border-e border-border bg-secondary flex flex-col items-center justify-center p-4">
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("sidebar_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-3 h-3 me-1" />
{t("reload")}
</Button>
</div>
<ErrorState className="w-64 flex-none border-e border-border bg-secondary p-4" title={t("sidebar_error")} onRetry={resetError} retryLabel={t("try_again")} />
);
}
/**
* Email list error fallback - matches email list panel width (384px).
*/
/** Mail's message list. */
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-full h-full bg-background flex flex-col items-center justify-center p-4">
<Inbox className="w-12 h-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("email_list_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-4 h-4 me-2" />
{t("reload_emails")}
</Button>
</div>
);
return <ErrorState className="w-full p-4" title={t("email_list_error")} onRetry={resetError} retryLabel={t("try_again")} />;
}
/**
* Email viewer error fallback - fills remaining space (flex-1).
*/
/** Mail's reading pane. */
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-muted/30 p-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<Mail className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("viewer_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6 max-w-md">
{t("viewer_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
<ErrorState
title={t("viewer_error_title")}
description={t("viewer_error_description")}
onRetry={resetError}
retryLabel={t("try_again")}
/>
);
}
/**
* Email composer modal error fallback.
*/
/** The composer. */
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
<AlertCircle className="w-10 h-10 text-warning mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("composer_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
{t("retry")}
</Button>
</div>
);
return <ErrorState className="border rounded-lg" title={t("composer_error")} onRetry={resetError} retryLabel={t("try_again")} />;
}
/**
* Settings page error fallback.
*/
/** Settings. */
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8">
<Settings className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{t("settings_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6">
{t("settings_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 me-2" />
{t("reload_settings")}
</Button>
</div>
<ErrorState
title={t("settings_error_title")}
description={t("settings_error_description")}
onRetry={resetError}
retryLabel={t("try_again")}
homeLabel={t("go_home")}
/>
);
}
+2
View File
@@ -1,6 +1,8 @@
export { ErrorBoundary } from "./error-boundary";
export { AppErrorBoundary } from "./app-error-boundary";
export type { FallbackProps } from "./error-boundary";
export {
ErrorState,
PageErrorFallback,
SidebarErrorFallback,
EmailListErrorFallback,