diff --git a/src/components/admin/studio/studio-client.contract.test.ts b/src/components/admin/studio/studio-client.contract.test.ts new file mode 100644 index 00000000..7e47600d --- /dev/null +++ b/src/components/admin/studio/studio-client.contract.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("StudioClient batch completion contract", () => { + it("marks React state without recursively invoking its completion callback", () => { + const source = readFileSync( + new URL("./studio-client.tsx", import.meta.url), + "utf8", + ); + const callbackBody = source.match( + /const markBatchDone = useCallback\(\(\) => \{([\s\S]*?)\n\t\}, \[\]\);/, + )?.[1]; + + expect(callbackBody).toBeDefined(); + expect(callbackBody).toContain("setBatchDone(true);"); + expect(callbackBody).not.toContain("markBatchDone();"); + }); + + it("resets the completion ref and React state before every batch", () => { + const source = readFileSync( + new URL("./studio-client.tsx", import.meta.url), + "utf8", + ); + const callbackBody = source.match( + /const resetBatchDone = useCallback\(\(\) => \{([\s\S]*?)\n\t\}, \[\]\);/, + )?.[1]; + + expect(callbackBody).toBeDefined(); + expect(callbackBody).toContain("batchDoneRef.current = false;"); + expect(callbackBody).toContain("setBatchDone(false);"); + expect(source.match(/resetBatchDone\(\);/g)).toHaveLength(4); + expect(source.match(/setBatchDone\(false\);/g)).toHaveLength(1); + }); +}); diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 70fa9d65..7781b3f3 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -154,7 +154,11 @@ export function StudioClient({ const batchDoneRef = useRef(false); const markBatchDone = useCallback(() => { batchDoneRef.current = true; - markBatchDone(); + setBatchDone(true); + }, []); + const resetBatchDone = useCallback(() => { + batchDoneRef.current = false; + setBatchDone(false); }, []); const [batchSucceeded, setBatchSucceeded] = useState(0); const [batchFailed, setBatchFailed] = useState(0); @@ -430,7 +434,7 @@ export function StudioClient({ initial.set(cn, { classname: cn, status: "pending" }); } setBatchProgress(initial); - setBatchDone(false); + resetBatchDone(); setBatchSucceeded(0); setBatchFailed(0); setFailedClassnames([]); @@ -693,7 +697,7 @@ export function StudioClient({ return; } - setBatchDone(false); + resetBatchDone(); setBatchSucceeded(0); setBatchFailed(0); setBatchCancelling(false); @@ -831,7 +835,7 @@ export function StudioClient({ async function retryFailed(failed: string[]) { if (!activeSource || failed.length === 0) return; // For clone sources, re-run cloneAllMissing with only the failed classnames - setBatchDone(false); + resetBatchDone(); setBatchSucceeded(0); setBatchFailed(0); setFailedClassnames([]); @@ -1957,7 +1961,7 @@ export function StudioClient({ onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch} onDismiss={() => { setBatchProgress(null); - setBatchDone(false); + resetBatchDone(); setVerification(null); setFailedClassnames([]); }}