From 9c31cdce37804c8de132b5d8bc9deca670957090 Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 17 Jul 2026 14:26:44 +0200 Subject: [PATCH] fix(custom-widgets): prevent editor crash on invalid v2 request manifest The customJsx v2 serialize path called parseRequestManifest which threw on invalid JSON during React render (via getInput on every keystroke), crashing the page with Next.js 'Unexpected error' because the form had no error boundary. - Make parseRequestManifest defensive (try/catch -> empty array) - Wrap CustomWidgetForm in FormErrorBoundary (defense-in-depth) - Add unit tests for invalid manifest inputs --- .../custom-widgets/_form-error-boundary.tsx | 54 +++++++++++++++++++ .../manage/custom-widgets/edit/[id]/page.tsx | 35 ++++++------ .../manage/custom-widgets/new/page.tsx | 5 +- .../custom-widgets/src/core/display-form.ts | 10 +++- .../src/test/display-form.spec.ts | 12 +++++ packages/translation/src/lang/en.json | 5 ++ 6 files changed, 102 insertions(+), 19 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/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 31eb269fa..2132d6e9e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -803,6 +803,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" @@ -4084,6 +4088,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",