Skip to content

Commit e8bdf7b

Browse files
GeneralJerelclaude
andcommitted
fix: revert save-template-overlay to synchronous ref pattern
The async useState+useEffect refactor caused matchedTemplate to change after first render, inserting a badge div before the iframe and shifting its child index. React reconciles by position, so this remounted the iframe and destroyed rendered 3D/canvas content. Restores the original synchronous ref with eslint-disable comments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a83a1ac commit e8bdf7b

File tree

1 file changed

+13
-14
lines changed

1 file changed

+13
-14
lines changed

apps/app/src/components/generative-ui/save-template-overlay.tsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useCallback, useEffect, useMemo, type ReactNode } from "react";
3+
import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
44
import { useAgent } from "@copilotkit/react-core/v2";
55
import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
66

@@ -35,29 +35,28 @@ export function SaveTemplateOverlay({
3535
const [saveState, setSaveState] = useState<SaveState>("idle");
3636
const [templateName, setTemplateName] = useState("");
3737

38-
// Capture pending_template once — it may be cleared by the agent later.
39-
// Syncs external agent state into local state (legitimate effect-based setState).
38+
// Capture pending_template at mount time — it may be cleared by the agent later.
39+
// Uses ref (not state) to avoid an async re-render that would shift sibling positions
40+
// and cause React to remount the iframe, losing rendered 3D/canvas content.
4041
const pending = agent.state?.pending_template as { id: string; name: string } | null | undefined;
41-
const [capturedSource, setCapturedSource] = useState<{ id: string; name: string } | null>(null);
42-
useEffect(() => {
43-
if (pending?.id) {
44-
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time capture of external agent state
45-
setCapturedSource((prev) => prev ?? pending);
46-
}
47-
}, [pending]);
42+
const sourceRef = useRef<{ id: string; name: string } | null>(null);
43+
// eslint-disable-next-line react-hooks/refs -- one-time ref init during render (React-endorsed pattern)
44+
if (pending?.id && !sourceRef.current) {
45+
sourceRef.current = pending; // eslint-disable-line react-hooks/refs
46+
}
4847

4948
// Check if this content matches an existing template:
5049
// 1. Exact HTML match (seed templates rendered as-is)
5150
// 2. Source template captured from pending_template (applied templates with modified data)
5251
const matchedTemplate = useMemo(() => {
5352
// First check source template from apply flow
54-
if (capturedSource) {
53+
if (sourceRef.current) { // eslint-disable-line react-hooks/refs
5554
const allTemplates = [
5655
...SEED_TEMPLATES,
5756
...((agent.state?.templates as { id: string; name: string }[]) || []),
5857
];
59-
const found = allTemplates.find((t) => t.id === capturedSource.id);
60-
if (found) return found;
58+
const source = allTemplates.find((t) => t.id === sourceRef.current!.id); // eslint-disable-line react-hooks/refs
59+
if (source) return source;
6160
}
6261
// Then check exact HTML match
6362
if (!html) return null;
@@ -68,7 +67,7 @@ export function SaveTemplateOverlay({
6867
...((agent.state?.templates as { id: string; name: string; html: string }[]) || []),
6968
];
7069
return allTemplates.find((t) => t.html && normalise(t.html) === norm) ?? null;
71-
}, [html, agent.state?.templates, capturedSource]);
70+
}, [html, agent.state?.templates]);
7271

7372
const handleSave = useCallback(() => {
7473
const name = templateName.trim() || title || "Untitled Template";

0 commit comments

Comments
 (0)