diff --git a/web/e2e/specs/error-boundary.spec.ts b/web/e2e/specs/error-boundary.spec.ts new file mode 100644 index 0000000000..f68c1115fe --- /dev/null +++ b/web/e2e/specs/error-boundary.spec.ts @@ -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(); + }); +}); diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 202e5cddc2..934e730c50 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -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": { diff --git a/web/src/App.tsx b/web/src/App.tsx index f6d43188ff..893819393d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 (
- } > - +
); } return (
- {isDesktop && } - {isDesktop && } - {isMobile && } + {isDesktop && } + {isDesktop && } + {isMobile && }
- } @@ -147,7 +148,7 @@ function DefaultAppView() { } /> } /> - +
); @@ -160,9 +161,9 @@ function SafeAppView() { id="pageRoot" className={cn("absolute bottom-0 left-0 right-0 top-0 overflow-hidden")} > - + - + ); diff --git a/web/src/components/ErrorBoundaries.tsx b/web/src/components/ErrorBoundaries.tsx new file mode 100644 index 0000000000..506dbe4923 --- /dev/null +++ b/web/src/components/ErrorBoundaries.tsx @@ -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 ( +
+ + + {stale ? t("error.staleTitle") : t("error.title")} + +

+ {stale ? t("error.staleDesc") : t("error.desc")} +

+ + {messageOf(failure.error)} + +
+ + +
+ +
+ ); +} + +/** Corner strip, so failed chrome never sits on top of the page content. */ +function ChromeNotice() { + const { t } = useTranslation(["common"]); + + return ( +
+ +
{t("error.partial")}
+ +
+ ); +} + +type TrapProps = { + children?: ReactNode; + panel: ComponentType; + resetToken: string; +}; + +type TrapState = { + token: string; + failure?: Failure; +}; + +class ErrorTrap extends Component { + state: TrapState = { token: this.props.resetToken }; + + static getDerivedStateFromError(error: unknown): Partial { + 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 ; + } +} + +type ChromeErrorBoundaryProps = { + children?: ReactNode; +}; + +/** Guards the sidebar, status bar, and bottom bar as one unit. */ +export function ChromeErrorBoundary({ children }: ChromeErrorBoundaryProps) { + const { pathname } = useLocation(); + + return ( + + {children} + + ); +} + +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 ( + + {children} + + ); +}