fix: stop manifest editor base image selection loop

The ManifestEditor had a feedback loop:
1. State change → buildManifest changes → onChange notifies parent
2. Parent updates manifestData → new manifest prop
3. Loading effect sets all state from manifest (arrays get new refs even if same content)
4. New array refs → buildManifest changes → onChange fires again → loop

Fix: track the last-sent manifest via a ref and only call onChange when the
serialized built manifest actually differs. This breaks the cycle because after
the loading effect syncs state, the rebuilt manifest is identical in content
so we skip the parent notification.
This commit is contained in:
Alex Blank
2026-05-28 21:44:49 +02:00
parent 3aa56dcfc3
commit 0a0af4e02a
3 changed files with 112 additions and 73 deletions
+13 -4
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { Icon } from "./icon";
import { extractErrorMessage } from "../utils/errors";
import {
@@ -170,11 +170,20 @@ export const ManifestEditor = ({
baseDefinitionId,
]);
// Notify parent of changes
// Notify parent of changes — only when built manifest actually differs
// from what we last sent, to avoid feedback loops with the manifest prop.
const lastSentRef = useRef<string>("");
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const m = buildManifest();
onChange(m);
}, [buildManifest, onChange]);
const serialized = JSON.stringify(m);
if (serialized !== lastSentRef.current) {
lastSentRef.current = serialized;
onChangeRef.current(m);
}
}, [buildManifest]);
const handlePreview = async () => {
if (!definitionId) {
+8 -14
View File
@@ -1,8 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import {
ErrorState,
LoadingState,
} from "../components/data-states";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors";
@@ -75,7 +72,6 @@ export const ToolWorkshopPage = () => {
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
const [toolTypeDirty, setToolTypeDirty] = useState(false);
const selectedToolType =
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
@@ -215,7 +211,9 @@ export const ToolWorkshopPage = () => {
return;
}
} else if (!manifestData) {
setToolTypeError("Manifest data is required for manifest definition type");
setToolTypeError(
"Manifest data is required for manifest definition type",
);
return;
}
@@ -300,14 +298,12 @@ export const ToolWorkshopPage = () => {
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interface_type: toolTypeForm.interface_type,
base_image:
(manifestData.base_image as string) || undefined,
base_image: (manifestData.base_image as string) || undefined,
base_definition_id:
(manifestData.base_definition_id as string) || undefined,
manifest: manifestData,
};
const newManifest =
await createToolDefinition(manifestPayload);
const newManifest = await createToolDefinition(manifestPayload);
manifestId = newManifest.id;
}
}
@@ -365,7 +361,6 @@ export const ToolWorkshopPage = () => {
}
};
if (status === "loading") {
return (
<div className="container">
@@ -935,7 +930,7 @@ export const ToolWorkshopPage = () => {
)}
</div>
{(
{
<form
onSubmit={handleToolTypeSubmit}
className="stack"
@@ -1265,8 +1260,7 @@ export const ToolWorkshopPage = () => {
)}
</div>
</form>
)}
}
</div>
)}
</div>