Compare commits

...
Author SHA1 Message Date
ajnart 7749458c31 style(e2e): format custom-widgets e2e test 2026-07-17 14:28:39 +02:00
ajnart 69788124c1 fix(custom-widgets): show edit-mode placeholder instead of definition not found
When a custom widget is added to a board in edit mode, the item only
exists in local TanStack cache (persisted on saveBoard at edit-mode
exit). The getData query fired immediately, hit the DB before the item
existed, threw NOT_FOUND, and retry was disabled so the widget stayed
on 'definition not found' until reload.

- Gate getData with !isEditMode so it only fires for persisted items
- Show an edit-mode placeholder instead of an empty box
- Add e2e regression test verifying no 'not found' before reload
2026-07-17 14:26:47 +02:00
ajnart 9c31cdce37 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
2026-07-17 14:26:44 +02:00
8 changed files with 194 additions and 21 deletions

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>
</>
+79
View File
@@ -80,4 +80,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);
});
@@ -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",
+13 -2
View File
@@ -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<string, unknown> | undefined;
if (
@@ -170,7 +170,18 @@ function CustomApiWidgetInner({
);
}
if (!data) return null;
if (!data) {
if (isEditMode) {
return (
<Center h="100%">
<Text c="dimmed" size="sm" ta="center">
{t("editModePending")}
</Text>
</Center>
);
}
return null;
}
const widgetData = data as Record<string, unknown>;
const dataType = widgetData.type as string | undefined;