From 69ba77f3340fd94de065d743fe24122adb6d08a5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Mar 2026 09:36:49 -0600 Subject: [PATCH] fix(ui): prevent infinite re-render loop on agent configure page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarkdownEditor's plugins useMemo depended on imageUploadHandler, which was a new arrow function on every parent render. This caused MDXEditor to reinitialize plugins on each render, firing onChange, which updated the overlay state in AgentConfigForm, triggering a parent re-render — creating an infinite "Maximum update depth exceeded" loop. Fix: use a stable ref for imageUploadHandler so plugins are only created once (keyed on whether a handler exists, not its identity). Also use a module-level empty object for env config fallback to avoid unnecessary EnvVarEditor re-renders. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/AgentConfigForm.tsx | 7 +++++-- ui/src/components/MarkdownEditor.tsx | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 391c3af8..3eb345ff 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -85,6 +85,9 @@ const emptyOverlay: Overlay = { runtime: {}, }; +/** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ +const EMPTY_ENV: Record = {}; + function isOverlayDirty(o: Overlay): boolean { return ( Object.keys(o.identity).length > 0 || @@ -617,8 +620,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ) - : ((eff("adapterConfig", "env", config.env ?? {}) as Record) + ? ((val!.envBindings ?? EMPTY_ENV) as Record) + : ((eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record)) ) } secrets={availableSecrets} diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index d9ec6fe7..c68face2 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -208,6 +208,10 @@ export const MarkdownEditor = forwardRef const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); + // Stable ref for imageUploadHandler so plugins don't recreate on every render + const imageUploadHandlerRef = useRef(imageUploadHandler); + imageUploadHandlerRef.current = imageUploadHandler; + // Mention state (ref kept in sync so callbacks always see the latest value) const [mentionState, setMentionState] = useState(null); const mentionStateRef = useRef(null); @@ -235,11 +239,17 @@ export const MarkdownEditor = forwardRef }, }), []); + // Whether the image plugin should be included (boolean is stable across renders + // as long as the handler presence doesn't toggle) + const hasImageUpload = Boolean(imageUploadHandler); + const plugins = useMemo(() => { - const imageHandler = imageUploadHandler + const imageHandler = hasImageUpload ? async (file: File) => { + const handler = imageUploadHandlerRef.current; + if (!handler) throw new Error("No image upload handler"); try { - const src = await imageUploadHandler(file); + const src = await handler(file); setUploadError(null); return src; } catch (err) { @@ -268,7 +278,7 @@ export const MarkdownEditor = forwardRef all.push(imagePlugin({ imageUploadHandler: imageHandler })); } return all; - }, [imageUploadHandler]); + }, [hasImageUpload]); useEffect(() => { if (value !== latestValueRef.current) {