From 3a1245cbc54f36cf1218673c800d6e75b02b1e4e Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 17 Jul 2026 14:39:29 +0200 Subject: [PATCH] fix(custom-widgets): fix v2 editor crash and definition-not-found race (#6377) --- .../custom-widgets/_form-error-boundary.tsx | 54 +++++++++++++ .../manage/custom-widgets/edit/[id]/page.tsx | 35 ++++---- .../manage/custom-widgets/new/page.tsx | 5 +- e2e/custom-widgets-custom-jsx.spec.ts | 79 +++++++++++++++++++ .../custom-widgets/src/core/display-form.ts | 10 ++- .../src/test/display-form.spec.ts | 12 +++ packages/translation/src/lang/en.json | 5 ++ packages/widgets/src/custom-api/component.tsx | 15 +++- 8 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/custom-widgets/_form-error-boundary.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_form-error-boundary.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_form-error-boundary.tsx new file mode 100644 index 000000000..b77b7cec1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_form-error-boundary.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { ErrorInfo, ReactNode } from "react"; +import { Component } from "react"; +import { Alert, Button, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +interface FormErrorBoundaryState { + error: Error | null; +} + +interface FormErrorBoundaryProps { + children: ReactNode; +} + +export class FormErrorBoundary extends Component { + public state: FormErrorBoundaryState = { error: null }; + + public static getDerivedStateFromError(error: Error): FormErrorBoundaryState { + return { error }; + } + + public componentDidCatch(error: Error, _info: ErrorInfo) { + console.error("Custom widget form error:", error); + } + + public render() { + if (this.state.error) { + return this.setState({ error: null })} />; + } + return this.props.children; + } +} + +function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) { + const t = useScopedI18n("customWidget"); + return ( + } p="md"> + + + {t("editor.errorBoundary.title")} + + + {error.message} + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx index 77439ac72..c98152f7a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/edit/[id]/page.tsx @@ -9,6 +9,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { catchTrpcNotFound } from "~/errors/trpc-catch-error"; import { CustomWidgetBetaBanner } from "../../_beta-banner"; import { CustomWidgetForm } from "../../_custom-widget-form"; +import { FormErrorBoundary } from "../../_form-error-boundary"; const authTypeExpectedSecrets: Record = { bearer: ["apiKey"], @@ -52,22 +53,24 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr {definition.name} - + + + diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx index 12a147997..a195f3a94 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx @@ -7,6 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { CustomWidgetBetaBanner } from "../_beta-banner"; import { CustomWidgetForm } from "../_custom-widget-form"; +import { FormErrorBoundary } from "../_form-error-boundary"; export default async function NewCustomWidgetPage() { const session = await auth(); @@ -28,7 +29,9 @@ export default async function NewCustomWidgetPage() { - + + + diff --git a/e2e/custom-widgets-custom-jsx.spec.ts b/e2e/custom-widgets-custom-jsx.spec.ts index 037691b8e..8dfeeb45b 100644 --- a/e2e/custom-widgets-custom-jsx.spec.ts +++ b/e2e/custom-widgets-custom-jsx.spec.ts @@ -84,4 +84,83 @@ describe("Custom JSX custom widgets", () => { await mockApi.stop(); } }, 120_000); + + test("newly added custom widget does not show definition not found before reload", async () => { + const mockApi = await startMockApiContainerAsync({ + title: "Board Widget", + status: "running", + value: 7, + }); + + const { db, localMountPath } = await createSqliteDbFileAsync(); + await seedAdminUserAsync(db, adminCredentials); + + const homarrContainer = await createHomarrContainer({ + environment: { + AUTH_PROVIDERS: "credentials", + }, + mounts: { + "/appdata": localMountPath, + }, + }).start(); + + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.goto(`${baseUrl}/auth/login`); + await page.getByLabel("Username").fill(adminCredentials.username); + await page.locator("#password").fill(adminCredentials.password); + await page.locator("css=button[type='submit']").click(); + await page.waitForURL(baseUrl, { timeout: 15_000 }); + + await page.goto(`${baseUrl}/manage/custom-widgets/new`); + await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 }); + await page.getByRole("textbox", { name: "Name" }).fill("Board Widget"); + await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`); + await page.getByRole("button", { name: /Custom JSX/u }).click(); + await page.getByRole("combobox", { name: "Network scope" }).click(); + await page.getByRole("option", { name: "Private networks" }).click(); + await page.getByLabel("JSX Template").fill(initialTemplate); + await page.getByRole("button", { name: "Create" }).first().click(); + await expect(page.getByText(/created successfully/u)).toBeVisible({ timeout: 15_000 }); + + await page.goto(baseUrl); + await page.waitForLoadState("networkidle"); + + await page + .getByRole("button", { name: /edit mode|Edit mode|pencil/iu }) + .first() + .click(); + + await page + .getByRole("button", { name: /Add widget|Add item/iu }) + .first() + .click(); + await page + .getByRole("button", { name: /Custom API|Custom Widget/iu }) + .first() + .click(); + + const addDialog = page.getByRole("dialog").last(); + await addDialog.getByText("Board Widget").first().click(); + + await expect(page.getByText("Widget definition not found")).not.toBeVisible({ timeout: 10_000 }); + + await page + .getByRole("button", { name: /edit mode|Edit mode|pencil/iu }) + .first() + .click(); + await page.waitForLoadState("networkidle"); + + await expect(page.getByText("Board Widget")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("Widget definition not found")).not.toBeVisible({ timeout: 10_000 }); + } finally { + await browser.close(); + await homarrContainer.stop(); + await mockApi.stop(); + } + }, 180_000); }); diff --git a/packages/custom-widgets/src/core/display-form.ts b/packages/custom-widgets/src/core/display-form.ts index c2c516721..3b1ae0deb 100644 --- a/packages/custom-widgets/src/core/display-form.ts +++ b/packages/custom-widgets/src/core/display-form.ts @@ -253,6 +253,12 @@ export function buildDisplayConfigFromFormValues(values: CustomWidgetDisplayForm } function parseRequestManifest(value: string) { - const parsed: unknown = JSON.parse(value); - return customJsxRequestSchema.array().parse(parsed); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return []; + } + const result = customJsxRequestSchema.array().safeParse(parsed); + return result.success ? result.data : []; } diff --git a/packages/custom-widgets/src/test/display-form.spec.ts b/packages/custom-widgets/src/test/display-form.spec.ts index d08b86681..4fb35bbbf 100644 --- a/packages/custom-widgets/src/test/display-form.spec.ts +++ b/packages/custom-widgets/src/test/display-form.spec.ts @@ -62,4 +62,16 @@ describe("custom-widget display form contracts", () => { requests: [{ id: "status", kind: "query", method: "GET" }], }); }); + + test("does not throw on invalid request manifest JSON while serializing v2 form state", () => { + const values = requiredFormValues("customJsx"); + values.jsxApiVersion = "2"; + + for (const invalidManifest of ["[invalid", "{not an array}", "", '{"id":1}', "null"]) { + values.requestManifest = invalidManifest; + expect(() => buildDisplayConfigFromFormValues(values)).not.toThrow(); + const config = buildDisplayConfigFromFormValues(values) as { requests?: unknown }; + expect(config.requests).toEqual([]); + } + }); }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 69a760e5b..69f4e16d5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -820,6 +820,10 @@ "editor": { "saveBlocked": "Resolve the editor errors before saving.", "saveReady": "The configuration is ready to save.", + "errorBoundary": { + "title": "Something went wrong", + "retry": "Try again" + }, "language": { "jsx": "JSX", "json": "JSON" @@ -4200,6 +4204,7 @@ }, "noDefinition": "No definition selected", "definitionNotFound": "Widget definition not found. It may have been deleted.", + "editModePending": "Save the board to load this widget.", "noDisplayOutput": "No display output", "openJson": "Open in browser JSON viewer", "execute": "Execute", diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx index 568b3852c..7438787a8 100644 --- a/packages/widgets/src/custom-api/component.tsx +++ b/packages/widgets/src/custom-api/component.tsx @@ -129,7 +129,7 @@ function CustomApiWidgetInner({ const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery( { itemId: itemId ?? "" }, { - enabled: Boolean(itemId), + enabled: Boolean(itemId) && !isEditMode, refetchInterval: (query) => { const result = query.state.data as Record | undefined; if ( @@ -170,7 +170,18 @@ function CustomApiWidgetInner({ ); } - if (!data) return null; + if (!data) { + if (isEditMode) { + return ( +
+ + {t("editModePending")} + +
+ ); + } + return null; + } const widgetData = data as Record; const dataType = widgetData.type as string | undefined;