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 = const isNumber =
(schema as { type?: string }).type === "integer" || (schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number"; (schema as { type?: string }).type === "number";
// Use a multi-line textarea for fields that tend to hold complex // Use a multi-line resizable textarea for fields that tend to hold
// multi-line values (PromQL, text blocks, etc.). The widget kind's // complex multi-line values (PromQL expressions, Grafana query strings,
// config schema can opt in via `format: "textarea"`; the well-known // markdown/text blocks, etc.). The widget kind's config schema can opt
// `query` field is treated as textarea by default. // in via `format: "textarea"`; the well-known field names below are
// treated as textarea by default.
const schemaFormat = (schema as { format?: string }).format; 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 ( return (
<Field <Field
key={key} key={key}
@@ -171,8 +173,8 @@ function WidgetConfigEditor({
{isTextarea ? ( {isTextarea ? (
<Textarea <Textarea
id={`widget-cfg-${key}`} id={`widget-cfg-${key}`}
rows={4} rows={6}
className="resize-y font-mono text-xs" className="resize font-mono text-xs min-h-[120px]"
value={String(config[key] ?? "")} value={String(config[key] ?? "")}
onChange={(e) => onChange({ ...config, [key]: e.target.value })} onChange={(e) => onChange({ ...config, [key]: e.target.value })}
/> />
@@ -209,15 +211,25 @@ export function WidgetConfigDialog({
}: Props) { }: Props) {
// When editing a dashboard (no serviceId), scope to dashboard-only widgets // 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. // (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,
!serviceId && dashboardScope ? "dashboard" : undefined, !serviceId && dashboardScope ? "dashboard" : undefined,
); );
const { data: services = [] } = useServiceInstances(); const instances = useMemo(() => instancesData ?? [], [instancesData]);
const { data: tasks = [] } = useTasks(); const { data: servicesData } = useServiceInstances();
const services = useMemo(() => servicesData ?? [], [servicesData]);
const { data: tasksData } = useTasks();
const tasks = useMemo(() => tasksData ?? [], [tasksData]);
const saveWidget = useSaveWidgetInstance(); const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance(); const deleteWidget = useDeleteWidgetInstance();
const { data: references = [] } = useWidgetReferences(dashboardScope); const { data: referencesData } = useWidgetReferences(dashboardScope);
const references = useMemo(() => referencesData ?? [], [referencesData]);
const createRef = useCreateWidgetReference(); const createRef = useCreateWidgetReference();
const deleteRef = useDeleteWidgetReference(); const deleteRef = useDeleteWidgetReference();
const detachRef = useDetachWidgetReference(); const detachRef = useDetachWidgetReference();
@@ -367,14 +379,15 @@ export function WidgetConfigDialog({
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at, (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 // 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 availableWidgets = useMemo(() => {
const onDashboard = new Set([ const onDashboard = new Set([
...instances.map((w) => w.id), ...instances.map((w) => w.id),
...referencedWidgetIds, ...references.map((r) => r.widget_id),
]); ]);
const search = existingSearch.toLowerCase().trim(); const search = existingSearch.toLowerCase().trim();
return allWidgets return allWidgets
@@ -384,8 +397,8 @@ export function WidgetConfigDialog({
!search || !search ||
w.title.toLowerCase().includes(search) || w.title.toLowerCase().includes(search) ||
w.widget_kind.toLowerCase().includes(search), w.widget_kind.toLowerCase().includes(search),
); );
}, [allWidgets, instances, referencedWidgetIds, existingSearch]); }, [allWidgets, instances, references, existingSearch]);
async function handleAddReference(widgetId: string) { async function handleAddReference(widgetId: string) {
await createRef.mutateAsync({ await createRef.mutateAsync({
+23 -8
View File
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
Dialog, Dialog,
@@ -99,20 +100,33 @@ function ServiceConfigFields({
const properties = const properties =
( (
type.config_schema as { type.config_schema as {
properties?: Record<string, { type?: string; description?: string }>; properties?: Record<string, { type?: string; description?: string; format?: string }>;
} }
).properties ?? {}; ).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 ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{Object.entries(properties).map(([key, schema]) => { {Object.entries(properties).map(([key, schema]) => {
const isNumber = schema.type === "integer" || schema.type === "number"; const isNumber = schema.type === "integer" || schema.type === "number";
const isTextarea = schema.format === "textarea" || TEXTAREA_KEYS.has(key);
return ( return (
<Field <Field
key={key} key={key}
label={key} label={key}
htmlFor={`cfg-${key}`} htmlFor={`cfg-${key}`}
helper={schema.description} 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 <Input
id={`cfg-${key}`} id={`cfg-${key}`}
type={isNumber ? "number" : "text"} type={isNumber ? "number" : "text"}
@@ -128,7 +142,8 @@ function ServiceConfigFields({
}) })
} }
/> />
</Field> )}
</Field>
); );
})} })}
</div> </div>