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:
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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user