Skip to content

Commit 38e8c5e

Browse files
authored
Merge pull request #51 from CopilotKit/fix/save-template-overlay-iframe
fix: resolve ES module bare specifiers in widget iframe
2 parents 1d442c4 + d4f4ea6 commit 38e8c5e

File tree

3 files changed

+49
-19
lines changed

3 files changed

+49
-19
lines changed

apps/agent/skills/advanced-visualization/SKILL.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,15 @@ Only these CDN origins work (CSP-enforced):
668668
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
669669
```
670670

671-
**Three.js** (3D graphics):
671+
**Three.js** (3D graphics) — use ES module import (import map resolves bare specifiers):
672+
```html
673+
<script type="module">
674+
import * as THREE from 'three';
675+
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
676+
// ... your Three.js code here
677+
</script>
678+
```
679+
Alternative UMD (global `THREE` variable):
672680
```html
673681
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
674682
```

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";

apps/app/src/components/generative-ui/widget-renderer.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,13 @@ window.addEventListener('message', function(e) {
428428
content.setAttribute('data-exec-' + hash, '1');
429429
try {
430430
var newScript = document.createElement('script');
431-
if (scriptInfo.type) newScript.type = scriptInfo.type;
431+
// Auto-detect ES module syntax: if the script contains import/export
432+
// statements but lacks type="module", promote it so the import map applies.
433+
var effectiveType = scriptInfo.type;
434+
if (!effectiveType && scriptInfo.text && /\\b(import\\s|export\\s|import\\()/.test(scriptInfo.text)) {
435+
effectiveType = 'module';
436+
}
437+
if (effectiveType) newScript.type = effectiveType;
432438
if (scriptInfo.src) {
433439
newScript.src = scriptInfo.src;
434440
newScript.onload = function() { runScripts(scripts, idx + 1); };
@@ -471,6 +477,20 @@ function assembleShell(initialHtml: string = ""): string {
471477
<head>
472478
<meta charset="utf-8">
473479
<meta name="viewport" content="width=device-width, initial-scale=1">
480+
<script type="importmap">
481+
{
482+
"imports": {
483+
"three": "https://esm.sh/three",
484+
"three/": "https://esm.sh/three/",
485+
"gsap": "https://esm.sh/gsap",
486+
"gsap/": "https://esm.sh/gsap/",
487+
"d3": "https://esm.sh/d3",
488+
"d3/": "https://esm.sh/d3/",
489+
"chart.js": "https://esm.sh/chart.js",
490+
"chart.js/": "https://esm.sh/chart.js/"
491+
}
492+
}
493+
</script>
474494
<meta http-equiv="Content-Security-Policy" content="
475495
default-src 'self';
476496
script-src 'unsafe-inline' 'unsafe-eval'
@@ -590,8 +610,8 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
590610

591611
const iframe = iframeRef.current;
592612
if (iframe.contentWindow) {
593-
// targetOrigin "*" is required: the sandboxed iframe (allow-scripts only,
594-
// no allow-same-origin) has a null origin, so no specific origin can be used.
613+
// targetOrigin "*" is required: sandboxed iframes may have a null origin
614+
// depending on browser, so no specific origin can be used.
595615
iframe.contentWindow.postMessage(
596616
{ type: "update-content", html },
597617
"*"
@@ -680,7 +700,10 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
680700
content streamed via postMessage for progressive rendering. */}
681701
<iframe
682702
ref={iframeRef}
683-
sandbox="allow-scripts"
703+
// allow-same-origin is required for import maps to work in srcdoc iframes.
704+
// Safe here because no auth/session data is exposed client-side.
705+
// See: https://github.com/CopilotKit/OpenGenerativeUI/issues/3
706+
sandbox="allow-scripts allow-same-origin"
684707
className="w-full border-0"
685708
onLoad={() => setLoaded(true)}
686709
style={{

0 commit comments

Comments
 (0)