mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-13 18:22:49 +03:00
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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -164,7 +164,8 @@
|
|||||||
"saveAll": "Save All",
|
"saveAll": "Save All",
|
||||||
"savingAll": "Saving All…",
|
"savingAll": "Saving All…",
|
||||||
"undoAll": "Undo All",
|
"undoAll": "Undo All",
|
||||||
"retry": "Retry"
|
"retry": "Retry",
|
||||||
|
"reload": "Reload"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"system": "System",
|
"system": "System",
|
||||||
@@ -314,6 +315,15 @@
|
|||||||
"title": "404",
|
"title": "404",
|
||||||
"desc": "Page not found"
|
"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}}",
|
"selectItem": "Select {{item}}",
|
||||||
"readTheDocumentation": "Read the documentation",
|
"readTheDocumentation": "Read the documentation",
|
||||||
"information": {
|
"information": {
|
||||||
|
|||||||
+11
-10
@@ -6,7 +6,7 @@ import Sidebar from "@/components/navigation/Sidebar";
|
|||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import Statusbar from "./components/Statusbar";
|
import Statusbar from "./components/Statusbar";
|
||||||
import Bottombar from "./components/navigation/Bottombar";
|
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 { Redirect } from "./components/navigation/Redirect";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
import { isPWA } from "./utils/isPWA";
|
import { isPWA } from "./utils/isPWA";
|
||||||
@@ -18,6 +18,7 @@ import { isRedirectingToLogin } from "@/api/auth-redirect";
|
|||||||
import { AuthContext } from "@/context/auth-context";
|
import { AuthContext } from "@/context/auth-context";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { isSetupDismissed } from "@/utils/setupWizard";
|
import { isSetupDismissed } from "@/utils/setupWizard";
|
||||||
|
import { ChromeErrorBoundary, LazyPage } from "@/components/ErrorBoundaries";
|
||||||
|
|
||||||
const Live = lazy(() => import("@/pages/Live"));
|
const Live = lazy(() => import("@/pages/Live"));
|
||||||
const Events = lazy(() => import("@/pages/Events"));
|
const Events = lazy(() => import("@/pages/Events"));
|
||||||
@@ -94,22 +95,22 @@ function DefaultAppView() {
|
|||||||
if (showWizard) {
|
if (showWizard) {
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
<Suspense
|
<LazyPage
|
||||||
fallback={
|
fallback={
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SetupWizard />
|
<SetupWizard />
|
||||||
</Suspense>
|
</LazyPage>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
{isDesktop && <Sidebar />}
|
<ChromeErrorBoundary>{isDesktop && <Sidebar />}</ChromeErrorBoundary>
|
||||||
{isDesktop && <Statusbar />}
|
<ChromeErrorBoundary>{isDesktop && <Statusbar />}</ChromeErrorBoundary>
|
||||||
{isMobile && <Bottombar />}
|
<ChromeErrorBoundary>{isMobile && <Bottombar />}</ChromeErrorBoundary>
|
||||||
<div
|
<div
|
||||||
id="pageRoot"
|
id="pageRoot"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -121,7 +122,7 @@ function DefaultAppView() {
|
|||||||
: "bottom-8 left-[52px]",
|
: "bottom-8 left-[52px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Suspense
|
<LazyPage
|
||||||
fallback={
|
fallback={
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<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="/unauthorized" element={<AccessDenied />} />
|
||||||
<Route path="*" element={<Redirect to="/" />} />
|
<Route path="*" element={<Redirect to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</LazyPage>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -160,9 +161,9 @@ function SafeAppView() {
|
|||||||
id="pageRoot"
|
id="pageRoot"
|
||||||
className={cn("absolute bottom-0 left-0 right-0 top-0 overflow-hidden")}
|
className={cn("absolute bottom-0 left-0 right-0 top-0 overflow-hidden")}
|
||||||
>
|
>
|
||||||
<Suspense>
|
<LazyPage>
|
||||||
<ConfigEditor />
|
<ConfigEditor />
|
||||||
</Suspense>
|
</LazyPage>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user