Add error boundary to frontend (#24255)

* add error boundary

on frontend errors, show a page-level recovery panel and a separate compact strip for the sidebar/status/bottombar so failures there don't take down the rest of the page

* tweaks

* fix test
This commit is contained in:
Josh Hawkins
2026-09-12 10:32:22 -06:00
committed by GitHub
parent dcfe5307c9
commit 4861a668b1
4 changed files with 495 additions and 11 deletions
+214
View File
@@ -0,0 +1,214 @@
/**
* App shell error boundaries.
*
* Both failures are forced through the mock layer rather than through test
* hooks in the app. A non-array payload reaches a component that treats it as
* a list and throws on the first render; aborting a page's asset request
* reproduces what an open tab sees after Frigate is updated underneath it.
*/
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import { grantClipboardPermissions, readClipboard } from "../helpers/clipboard";
/** Exports builds its list with `rawExports.filter(...)`. */
async function breakExportsPage(app: FrigateApp) {
await app.page.route("**/api/exports**", (route) =>
route.fulfill({ json: { unexpected: true } }),
);
await app.goto("/export");
await expect(app.page.getByTestId("error-panel")).toBeVisible({
timeout: 10_000,
});
}
/**
* `useStats` runs `Object.entries(stats.detectors)` and only the status bar
* and bottom bar call it, so null detectors throw in one chrome component.
*/
async function breakStatusbar(app: FrigateApp) {
await app.installDefaults({ stats: { detectors: null } });
await app.goto("/");
}
/** GeneralSettings and the status bar both read profiles, so both throw. */
async function breakAllDesktopChrome(app: FrigateApp) {
await app.page.route("**/api/profiles**", (route) =>
route.fulfill({
json: { profiles: { broken: true }, active_profile: "default" },
}),
);
await app.goto("/");
}
test.describe("Error boundaries - page failure @high", () => {
// React mirrors the caught error to console.error, and the panel shows the
// TypeError message on purpose.
test.use({
expectedErrors: [
/is not a function|An error occurred in the|The above error occurred/,
],
});
test("a thrown page renders a recovery panel, not a blank screen", async ({
frigateApp,
}) => {
await breakExportsPage(frigateApp);
const panel = frigateApp.page.getByTestId("error-panel");
await expect(panel.getByText("This page stopped working")).toBeVisible();
await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible();
await expect(panel.getByTestId("error-panel-message")).toContainText(
"is not a function",
);
// Contained to the route: the panel sits in the page container and the
// chrome boundary never trips, so navigation stays usable.
await expect(
frigateApp.page.locator("#pageRoot [data-testid='error-panel']"),
).toBeVisible();
await expect(frigateApp.page.getByTestId("error-strip")).toHaveCount(0);
await expect(frigateApp.page.locator('a[href="/"]').first()).toBeVisible();
});
test("Copy details puts a triageable report on the clipboard", async ({
frigateApp,
context,
}) => {
await grantClipboardPermissions(context);
await breakExportsPage(frigateApp);
await frigateApp.page
.getByTestId("error-panel")
.getByRole("button", { name: "Copy details" })
.click();
await expect
.poll(() => readClipboard(frigateApp.page), { timeout: 5_000 })
.toContain("Frigate UI crash report");
const report = await readClipboard(frigateApp.page);
expect(report).toContain("Version: 0.15.0-test");
expect(report).toMatch(/^Page: http/m);
expect(report).toContain("is not a function");
});
test("navigating away drops the panel", async ({ frigateApp }) => {
await breakExportsPage(frigateApp);
await frigateApp.page.locator('a[href="/"]').first().click();
await expect(frigateApp.page).toHaveURL(/\/$/);
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
test("@mobile the panel leaves the bottom bar reachable", async ({
frigateApp,
}) => {
test.skip(!frigateApp.isMobile, "Mobile-only assertion");
await breakExportsPage(frigateApp);
await expect(
frigateApp.page
.getByTestId("error-panel")
.getByRole("button", { name: "Reload" }),
).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/review"]').first(),
).toBeVisible();
});
});
test.describe("Error boundaries - chrome failure @high", () => {
test.use({
expectedErrors: [
/is not a function|An error occurred in the|The above error occurred/,
],
});
test("a thrown status bar leaves the sidebar usable", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "The status bar is desktop chrome");
await breakStatusbar(frigateApp);
const strip = frigateApp.page.getByTestId("error-strip");
await expect(strip).toBeVisible({ timeout: 10_000 });
await expect(strip).toHaveCount(1);
await expect(strip.getByRole("button", { name: "Reload" })).toBeVisible();
// Each chrome component owns a boundary, so the sidebar outlives the
// status bar and the user can still navigate out.
await expect(frigateApp.page.locator("aside")).toBeVisible();
await expect(
frigateApp.page.locator('a[href="/review"]').first(),
).toBeVisible();
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
});
test("chrome failures never reach the page content", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop chrome");
await breakAllDesktopChrome(frigateApp);
await expect(
frigateApp.page.getByTestId("error-strip").first(),
).toBeVisible({ timeout: 10_000 });
await expect(frigateApp.page.getByTestId("error-panel")).toHaveCount(0);
await expect(
frigateApp.page.locator("[data-camera='front_door']"),
).toBeVisible({ timeout: 10_000 });
});
});
test.describe("Error boundaries - clipboard refused @high", () => {
test.use({
expectedErrors: [
/is not a function|An error occurred in the|The above error occurred/,
],
});
test("a refused clipboard write reports the failure", async ({
frigateApp,
}) => {
// copy-to-clipboard treats a false return from execCommand as a failure
// and falls back to a prompt, which Playwright dismisses on its own.
await frigateApp.page.addInitScript(() => {
document.execCommand = () => false;
});
await breakExportsPage(frigateApp);
await frigateApp.page
.getByTestId("error-panel")
.getByRole("button", { name: "Copy details" })
.click();
await expect(
frigateApp.page.getByText("Could not copy details to clipboard"),
).toBeVisible();
});
});
test.describe("Error boundaries - stale assets @high", () => {
test.use({
expectedErrors: [
/Failed to fetch dynamically imported module|Importing a module script failed|net::ERR_FAILED|An error occurred in the|The above error occurred/,
],
});
test("a missing page chunk asks for a reload", async ({ frigateApp }) => {
await frigateApp.page.route(
/\/assets\/Exports-[^/]+\.js(\?.*)?$/,
(route) => route.abort("failed"),
);
await frigateApp.goto("/");
await frigateApp.page.locator('a[href="/export"]').first().click();
const panel = frigateApp.page.getByTestId("error-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
await expect(panel.getByText("Reload required")).toBeVisible();
await expect(panel.getByRole("button", { name: "Reload" })).toBeVisible();
});
});
+11 -1
View File
@@ -164,7 +164,8 @@
"saveAll": "Save All",
"savingAll": "Saving All…",
"undoAll": "Undo All",
"retry": "Retry"
"retry": "Retry",
"reload": "Reload"
},
"menu": {
"system": "System",
@@ -314,6 +315,15 @@
"title": "404",
"desc": "Page not found"
},
"error": {
"title": "This page stopped working",
"desc": "The page ran into an unexpected error. Reloading usually clears it. If it keeps happening, copy the details and attach them to a discussion on GitHub.",
"staleTitle": "Reload required",
"staleDesc": "Part of the interface could not be loaded, which usually means Frigate was updated while this tab was open. Reload to pick up the new version.",
"partial": "Part of the interface stopped working.",
"copyDetails": "Copy details",
"copyFailed": "Could not copy details to clipboard"
},
"selectItem": "Select {{item}}",
"readTheDocumentation": "Read the documentation",
"information": {
+11 -10
View File
@@ -6,7 +6,7 @@ import Sidebar from "@/components/navigation/Sidebar";
import { isDesktop, isMobile } from "react-device-detect";
import Statusbar from "./components/Statusbar";
import Bottombar from "./components/navigation/Bottombar";
import { Suspense, lazy, useContext, useEffect, useState } from "react";
import { lazy, useContext, useEffect, useState } from "react";
import { Redirect } from "./components/navigation/Redirect";
import { cn } from "./lib/utils";
import { isPWA } from "./utils/isPWA";
@@ -18,6 +18,7 @@ import { isRedirectingToLogin } from "@/api/auth-redirect";
import { AuthContext } from "@/context/auth-context";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { isSetupDismissed } from "@/utils/setupWizard";
import { ChromeErrorBoundary, LazyPage } from "@/components/ErrorBoundaries";
const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events"));
@@ -94,22 +95,22 @@ function DefaultAppView() {
if (showWizard) {
return (
<div className="size-full overflow-hidden">
<Suspense
<LazyPage
fallback={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
>
<SetupWizard />
</Suspense>
</LazyPage>
</div>
);
}
return (
<div className="size-full overflow-hidden">
{isDesktop && <Sidebar />}
{isDesktop && <Statusbar />}
{isMobile && <Bottombar />}
<ChromeErrorBoundary>{isDesktop && <Sidebar />}</ChromeErrorBoundary>
<ChromeErrorBoundary>{isDesktop && <Statusbar />}</ChromeErrorBoundary>
<ChromeErrorBoundary>{isMobile && <Bottombar />}</ChromeErrorBoundary>
<div
id="pageRoot"
className={cn(
@@ -121,7 +122,7 @@ function DefaultAppView() {
: "bottom-8 left-[52px]",
)}
>
<Suspense
<LazyPage
fallback={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
@@ -147,7 +148,7 @@ function DefaultAppView() {
<Route path="/unauthorized" element={<AccessDenied />} />
<Route path="*" element={<Redirect to="/" />} />
</Routes>
</Suspense>
</LazyPage>
</div>
</div>
);
@@ -160,9 +161,9 @@ function SafeAppView() {
id="pageRoot"
className={cn("absolute bottom-0 left-0 right-0 top-0 overflow-hidden")}
>
<Suspense>
<LazyPage>
<ConfigEditor />
</Suspense>
</LazyPage>
</div>
</div>
);
+259
View File
@@ -0,0 +1,259 @@
/**
* Error boundaries for the app shell.
*
* A throw during render normally unmounts the whole tree and leaves a blank
* screen behind. These boundaries stop that at the page container and at the
* navigation chrome, so one broken view cannot take the rest of the UI with
* it. Only render, commit, and lifecycle throws land here; event handlers,
* timers, and rejected fetches are still on their own.
*/
import {
Component,
Suspense,
type ComponentType,
type ErrorInfo,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { FaExclamationTriangle } from "react-icons/fa";
import { LuCopy, LuRefreshCw } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import Heading from "@/components/ui/heading";
import { Toaster } from "@/components/ui/sonner";
import { useAutoFrigateStats } from "@/hooks/use-stats";
type Failure = {
error: unknown;
componentStack?: string;
};
type PanelProps = {
failure: Failure;
};
// A new build replaces the hashed asset files, so a tab left open across an
// update asks for files that are gone. Browsers word that failure their own
// way, which leaves these fragments as the only common signal.
const STALE_ASSET_HINTS = [
"chunkloaderror",
"loading chunk",
"loading css chunk",
"dynamically imported module",
"module script failed",
];
function messageOf(error: unknown): string {
if (error instanceof Error) {
return error.message || error.name;
}
return String(error);
}
function isStaleAsset({ error }: Failure): boolean {
const name = error instanceof Error ? error.name : "";
const text = `${name} ${messageOf(error)}`.toLowerCase();
return STALE_ASSET_HINTS.some((hint) => text.includes(hint));
}
/** Version goes first so a pasted report is triageable on its own. */
function crashReport({ error, componentStack }: Failure, version?: string) {
const stack = error instanceof Error ? error.stack : undefined;
return [
"Frigate UI crash report",
`Version: ${version || "unknown"}`,
`Page: ${window.location.href}`,
`Browser: ${navigator.userAgent}`,
`When: ${new Date().toISOString()}`,
`Error: ${messageOf(error)}`,
stack && `\nStack:\n${stack}`,
componentStack && `\nComponents:${componentStack.trimEnd()}`,
]
.filter(Boolean)
.join("\n");
}
function reloadPage() {
window.location.reload();
}
function PagePanel({ failure }: PanelProps) {
const { t } = useTranslation(["common"]);
// service.version carries the release and the build commit, and the status
// bar already keeps it warm. config.version is the config schema version.
const stats = useAutoFrigateStats();
const stale = isStaleAsset(failure);
const onCopy = () => {
// copy() falls back to a prompt and returns false when the clipboard is
// refused, so a success toast has to wait on the result.
const copied = copy(crashReport(failure, stats?.service.version));
if (copied) {
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
} else {
toast.error(t("error.copyFailed"), { position: "top-center" });
}
};
return (
<div
role="alert"
data-testid="error-panel"
className="flex size-full flex-col items-center justify-center overflow-auto p-4 text-center"
>
<FaExclamationTriangle className="mb-4 size-8 text-danger" />
<Heading as="h2" className="mb-2">
{stale ? t("error.staleTitle") : t("error.title")}
</Heading>
<p className="max-w-md text-primary-variant">
{stale ? t("error.staleDesc") : t("error.desc")}
</p>
<code
data-testid="error-panel-message"
className="my-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-secondary px-3 py-2 text-left text-xs text-primary"
>
{messageOf(failure.error)}
</code>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
size="sm"
variant="select"
className="flex items-center gap-2"
aria-label={t("button.reload")}
onClick={reloadPage}
>
<LuRefreshCw className="size-4" />
{t("button.reload")}
</Button>
<Button
size="sm"
variant="outline"
className="flex items-center gap-2"
aria-label={t("error.copyDetails")}
onClick={onCopy}
>
<LuCopy className="size-4" />
{t("error.copyDetails")}
</Button>
</div>
<Toaster position="top-center" closeButton={true} />
</div>
);
}
/** Corner strip, so failed chrome never sits on top of the page content. */
function ChromeNotice() {
const { t } = useTranslation(["common"]);
return (
<div
role="alert"
data-testid="error-strip"
className="absolute bottom-0 left-0 z-50 flex max-w-full items-center gap-2 rounded-tr-md bg-secondary px-3 py-1.5 text-xs text-primary shadow-md"
>
<FaExclamationTriangle className="size-4 shrink-0 text-danger" />
<div className="truncate">{t("error.partial")}</div>
<Button
variant="link"
className="h-auto p-0 text-xs"
aria-label={t("button.reload")}
onClick={reloadPage}
>
{t("button.reload")}
</Button>
</div>
);
}
type TrapProps = {
children?: ReactNode;
panel: ComponentType<PanelProps>;
resetToken: string;
};
type TrapState = {
token: string;
failure?: Failure;
};
class ErrorTrap extends Component<TrapProps, TrapState> {
state: TrapState = { token: this.props.resetToken };
static getDerivedStateFromError(error: unknown): Partial<TrapState> {
return { failure: { error } };
}
// Dropping the failure here beats both alternatives: keying the boundary on
// the route would remount the whole tree on every navigation, and resetting
// from componentDidUpdate would paint the dead panel one more time first.
static getDerivedStateFromProps(props: TrapProps, state: TrapState) {
if (props.resetToken === state.token) {
return null;
}
return { token: props.resetToken, failure: undefined };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
// React logs the error on its own. The component stack is the part worth
// holding on to, since it names the subtree that threw.
this.setState({
failure: { error, componentStack: info.componentStack ?? undefined },
});
}
render() {
const { failure } = this.state;
if (!failure) {
return this.props.children;
}
const Panel = this.props.panel;
return <Panel failure={failure} />;
}
}
type ChromeErrorBoundaryProps = {
children?: ReactNode;
};
/** Guards the sidebar, status bar, and bottom bar as one unit. */
export function ChromeErrorBoundary({ children }: ChromeErrorBoundaryProps) {
const { pathname } = useLocation();
return (
<ErrorTrap panel={ChromeNotice} resetToken={pathname}>
{children}
</ErrorTrap>
);
}
type LazyPageProps = {
children?: ReactNode;
fallback?: ReactNode;
};
/**
* Suspense for a lazily loaded page, plus the recovery panel it needs when
* the chunk fails to arrive or the page throws on its first render.
*/
export function LazyPage({ children, fallback }: LazyPageProps) {
const { pathname } = useLocation();
return (
<ErrorTrap panel={PagePanel} resetToken={pathname}>
<Suspense fallback={fallback}>{children}</Suspense>
</ErrorTrap>
);
}