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
This commit is contained in:
ajnart committed 2026-07-17 14:26:44 +02:00
1 parent 67d146220a
commit 9c31cdce37
6 files changed
+102 -19

No files matched your search

@@ -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<FormErrorBoundaryProps, FormErrorBoundaryState> {
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 <FormErrorFallback error={this.state.error} reset={() => this.setState({ error: null })} />;
}
return this.props.children;
}
}
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
const t = useScopedI18n("customWidget");
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
<Stack gap="xs">
<Text size="sm" fw={600}>
{t("editor.errorBoundary.title")}
</Text>
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{error.message}
</Text>
<Button size="xs" variant="light" color="red" onClick={reset}>
{t("editor.errorBoundary.retry")}
</Button>
</Stack>
</Alert>
);
}
@@ -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<string, string[]> = {
bearer: ["apiKey"],
@@ -52,22 +53,24 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
<Stack>
<Title>{definition.name}</Title>
<CustomWidgetBetaBanner />
<CustomWidgetForm
mode="edit"
definitionId={params.id}
initialValues={{
name: definition.name,
description: definition.description ?? "",
iconUrl: definition.iconUrl ?? "",
url: definition.url,
authType: definition.authType,
headerName: definition.headerName ?? "",
method: definition.method,
requestBody: definition.requestBody ?? "",
...displayValues,
secrets: buildInitialSecrets(definition.authType, definition.secrets),
}}
/>
<FormErrorBoundary>
<CustomWidgetForm
mode="edit"
definitionId={params.id}
initialValues={{
name: definition.name,
description: definition.description ?? "",
iconUrl: definition.iconUrl ?? "",
url: definition.url,
authType: definition.authType,
headerName: definition.headerName ?? "",
method: definition.method,
requestBody: definition.requestBody ?? "",
...displayValues,
secrets: buildInitialSecrets(definition.authType, definition.secrets),
}}
/>
</FormErrorBoundary>
</Stack>
</Container>
</>
@@ -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() {
</Text>
</div>
<CustomWidgetBetaBanner />
<CustomWidgetForm mode="create" />
<FormErrorBoundary>
<CustomWidgetForm mode="create" />
</FormErrorBoundary>
</Stack>
</Container>
</>
@@ -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 : [];
}
@@ -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([]);
}
});
});
+5
View File
@@ -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",