fix: widget edit crash (#185) + resizable textarea for complex fields

WidgetConfigDialog crashed on edit with React error #185 (Maximum update
depth exceeded) when the references/instances query returned undefined and
the inline '= []' fallback created a new array ref every render, looping the
auto-edit useEffect. Stabilize via useMemo(data ?? []). Also moved the
referencedWidgetIds Set inside the availableWidgets useMemo (clears the
pre-existing exhaustive-deps warning).

Complex config fields (promql, query, text, command, notes, or opt-in via
format: 'textarea') now render as a taller resizable Textarea (rows=6,
min-h-120px, font-mono, resize) in both WidgetConfigFields and
ServiceConfigFields, instead of a single-line Input.

Build + lint clean (referencedWidgetIds warning gone), 165 vitest pass.
This commit is contained in:
Developer
2026-07-11 10:31:57 +00:00
parent 044d386ac7
commit ecabc65dd4
2 changed files with 53 additions and 25 deletions
+30 -17
View File
@@ -155,12 +155,14 @@ function WidgetConfigEditor({
const isNumber =
(schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number";
// Use a multi-line textarea for fields that tend to hold complex
// multi-line values (PromQL, text blocks, etc.). The widget kind's
// config schema can opt in via `format: "textarea"`; the well-known
// `query` field is treated as textarea by default.
// Use a multi-line resizable textarea for fields that tend to hold
// complex multi-line values (PromQL expressions, Grafana query strings,
// markdown/text blocks, etc.). The widget kind's config schema can opt
// in via `format: "textarea"`; the well-known field names below are
// treated as textarea by default.
const schemaFormat = (schema as { format?: string }).format;
const isTextarea = schemaFormat === "textarea" || key === "query";
const TEXTAREA_KEYS = new Set(["promql", "query", "text", "command", "notes"]);
const isTextarea = schemaFormat === "textarea" || TEXTAREA_KEYS.has(key);
return (
<Field
key={key}
@@ -171,8 +173,8 @@ function WidgetConfigEditor({
{isTextarea ? (
<Textarea
id={`widget-cfg-${key}`}
rows={4}
className="resize-y font-mono text-xs"
rows={6}
className="resize font-mono text-xs min-h-[120px]"
value={String(config[key] ?? "")}
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
/>
@@ -209,15 +211,25 @@ export function WidgetConfigDialog({
}: Props) {
// When editing a dashboard (no serviceId), scope to dashboard-only widgets
// (service_id IS NULL) so service-scoped widgets don't leak into the list.
const { data: instances = [] } = useWidgetInstances(
// NOTE: stabilize the `data ?? []` defaults via useMemo. Using the inline
// `= []` fallback would create a NEW array reference on every render, which
// feeds the auto-edit useEffect below (deps include `instances`/`references`)
// and causes React error #185 (Maximum update depth exceeded) when the
// underlying query returns undefined — e.g. editing a service-scoped widget
// where `dashboardScope` is undefined and `useWidgetReferences` yields no data.
const { data: instancesData } = useWidgetInstances(
serviceId,
!serviceId && dashboardScope ? "dashboard" : undefined,
);
const { data: services = [] } = useServiceInstances();
const { data: tasks = [] } = useTasks();
const instances = useMemo(() => instancesData ?? [], [instancesData]);
const { data: servicesData } = useServiceInstances();
const services = useMemo(() => servicesData ?? [], [servicesData]);
const { data: tasksData } = useTasks();
const tasks = useMemo(() => tasksData ?? [], [tasksData]);
const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance();
const { data: references = [] } = useWidgetReferences(dashboardScope);
const { data: referencesData } = useWidgetReferences(dashboardScope);
const references = useMemo(() => referencesData ?? [], [referencesData]);
const createRef = useCreateWidgetReference();
const deleteRef = useDeleteWidgetReference();
const detachRef = useDetachWidgetReference();
@@ -367,14 +379,15 @@ export function WidgetConfigDialog({
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
// Available widgets for the "Add existing" picker: all widgets not already
// on this dashboard (owned or referenced).
// on this dashboard (owned or referenced). The referenced-id Set is built
// INSIDE the memo so its identity is stable across renders (building it in
// the render body would change the memo's deps every render and recompute
// it every frame — the lint-flagged footgun).
const availableWidgets = useMemo(() => {
const onDashboard = new Set([
...instances.map((w) => w.id),
...referencedWidgetIds,
...references.map((r) => r.widget_id),
]);
const search = existingSearch.toLowerCase().trim();
return allWidgets
@@ -384,8 +397,8 @@ export function WidgetConfigDialog({
!search ||
w.title.toLowerCase().includes(search) ||
w.widget_kind.toLowerCase().includes(search),
);
}, [allWidgets, instances, referencedWidgetIds, existingSearch]);
);
}, [allWidgets, instances, references, existingSearch]);
async function handleAddReference(widgetId: string) {
await createRef.mutateAsync({
+23 -8
View File
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
@@ -99,20 +100,33 @@ function ServiceConfigFields({
const properties =
(
type.config_schema as {
properties?: Record<string, { type?: string; description?: string }>;
properties?: Record<string, { type?: string; description?: string; format?: string }>;
}
).properties ?? {};
// Multi-line resizable textarea for fields that hold complex values (opt-in
// via `format: "textarea"`, or well-known multi-line keys).
const TEXTAREA_KEYS = new Set(["notes", "command"]);
return (
<div className="flex flex-col gap-3">
{Object.entries(properties).map(([key, schema]) => {
const isNumber = schema.type === "integer" || schema.type === "number";
const isTextarea = schema.format === "textarea" || TEXTAREA_KEYS.has(key);
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
{isTextarea ? (
<Textarea
id={`cfg-${key}`}
rows={6}
className="resize font-mono text-xs min-h-[120px]"
value={String(config[key] ?? "")}
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
/>
) : (
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
@@ -128,7 +142,8 @@ function ServiceConfigFields({
})
}
/>
</Field>
)}
</Field>
);
})}
</div>