Skip to content

Commit 77eeafa

Browse files
committed
fix: prevent iframe reload on re-renders, add loading phrase animation
Replace React-managed srcDoc prop with imperative srcdoc writes to preserve iframe JS state (Three.js scenes, step counters) across CopilotKit re-renders. Add cycling loading phrase indicator with shimmer animation.
1 parent df2a78f commit 77eeafa

2 files changed

Lines changed: 111 additions & 28 deletions

File tree

apps/app/src/app/globals.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,3 +554,13 @@ body, html {
554554
0% { outline: 2px solid rgba(190, 194, 255, 0.5); outline-offset: 2px; }
555555
100% { outline: 2px solid transparent; outline-offset: 8px; }
556556
}
557+
558+
/* === Widget Loading Animations === */
559+
@keyframes shimmer {
560+
0% { background-position: 200% 0; }
561+
100% { background-position: -200% 0; }
562+
}
563+
564+
@keyframes spin {
565+
to { transform: rotate(360deg); }
566+
}

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

Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,37 @@ function assembleDocument(html: string): string {
396396
</html>`;
397397
}
398398

399+
// ─── Loading Phrases ─────────────────────────────────────────────────
400+
const LOADING_PHRASES = [
401+
"Sketching pixels",
402+
"Wiring up nodes",
403+
"Painting gradients",
404+
"Compiling visuals",
405+
"Arranging atoms",
406+
"Rendering magic",
407+
"Polishing edges",
408+
];
409+
410+
function useLoadingPhrase(active: boolean) {
411+
const [index, setIndex] = useState(0);
412+
useEffect(() => {
413+
if (!active) return;
414+
setIndex(0);
415+
const interval = setInterval(() => {
416+
setIndex((i) => (i + 1) % LOADING_PHRASES.length);
417+
}, 1800);
418+
return () => clearInterval(interval);
419+
}, [active]);
420+
return LOADING_PHRASES[index];
421+
}
422+
399423
// ─── React Component ─────────────────────────────────────────────────
400424
export function WidgetRenderer({ title, description, html }: WidgetRendererProps) {
401425
const iframeRef = useRef<HTMLIFrameElement>(null);
402426
const [height, setHeight] = useState(0);
403427
const [loaded, setLoaded] = useState(false);
428+
// Track what html has been committed to the iframe to avoid redundant reloads
429+
const committedHtmlRef = useRef("");
404430

405431
const handleMessage = useCallback((e: MessageEvent) => {
406432
// Only handle messages from our own iframe
@@ -419,47 +445,94 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
419445
return () => window.removeEventListener("message", handleMessage);
420446
}, [handleMessage]);
421447

422-
// Reset loaded state when html changes
448+
// Write to iframe imperatively — bypasses React reconciliation so the
449+
// iframe only reloads when the html *content* truly changes, preserving
450+
// internal JS state (Three.js scenes, step counters, etc.) across
451+
// CopilotKit re-renders.
423452
useEffect(() => {
453+
if (!html || !iframeRef.current) return;
454+
if (html === committedHtmlRef.current) return;
455+
committedHtmlRef.current = html;
456+
iframeRef.current.srcdoc = assembleDocument(html);
424457
setLoaded(false);
425458
setHeight(0);
426459
}, [html]);
427460

461+
// Fallback: if iframe has html but hasn't reported ready after 4s, force-show
462+
useEffect(() => {
463+
if (!html || (loaded && height > 0)) return;
464+
const timeout = setTimeout(() => {
465+
setLoaded(true);
466+
setHeight((h) => (h > 0 ? h : 500));
467+
}, 4000);
468+
return () => clearTimeout(timeout);
469+
}, [html, loaded, height]);
470+
428471
// Iframe is ready when it has loaded AND reported a valid height
429472
const ready = loaded && height > 0;
430-
431-
const srcdoc = html ? assembleDocument(html) : "";
473+
const showLoading = !!html && !ready;
474+
const loadingPhrase = useLoadingPhrase(showLoading);
432475

433476
return (
434477
<div className="w-full my-3">
435-
{/* Skeleton: visible until iframe is fully ready */}
436-
{!ready && (
437-
<div className="animate-pulse">
438-
<div className="space-y-3 py-4">
439-
<div className="h-4 bg-gray-200 dark:bg-zinc-700 rounded w-3/4" />
440-
<div className="h-32 bg-gray-200 dark:bg-zinc-700 rounded" />
441-
<div className="h-4 bg-gray-200 dark:bg-zinc-700 rounded w-1/2" />
442-
</div>
443-
</div>
444-
)}
445-
{/* Iframe: rendered when html exists, but invisible until ready */}
446-
{html && (
447-
<iframe
448-
ref={iframeRef}
449-
srcDoc={srcdoc}
450-
sandbox="allow-scripts allow-same-origin"
451-
className="w-full border-0"
452-
onLoad={() => setLoaded(true)}
478+
{/* Loading indicator: visible until iframe is fully ready */}
479+
{showLoading && (
480+
<div
481+
className="overflow-hidden rounded-xl"
453482
style={{
454-
height: ready ? height : 0,
455-
overflow: "hidden",
456-
background: "transparent",
457-
opacity: ready ? 1 : 0,
458-
transition: "opacity 300ms ease-in",
483+
border: "1px solid var(--color-border-glass)",
484+
background: "var(--surface-primary)",
459485
}}
460-
title={title}
461-
/>
486+
>
487+
{/* Animated gradient border top */}
488+
<div
489+
style={{
490+
height: 2,
491+
background: "linear-gradient(90deg, var(--color-lilac), var(--color-mint), var(--color-lilac))",
492+
backgroundSize: "200% 100%",
493+
animation: "shimmer 1.5s ease-in-out infinite",
494+
}}
495+
/>
496+
<div className="flex items-center gap-3 px-4 py-3">
497+
{/* Spinning icon */}
498+
<div
499+
style={{
500+
width: 18,
501+
height: 18,
502+
borderRadius: "50%",
503+
border: "2px solid var(--color-border-light)",
504+
borderTopColor: "var(--color-lilac-dark)",
505+
animation: "spin 0.8s linear infinite",
506+
flexShrink: 0,
507+
}}
508+
/>
509+
<span
510+
className="text-[13px] font-medium"
511+
style={{ color: "var(--text-secondary)" }}
512+
>
513+
{loadingPhrase}...
514+
</span>
515+
</div>
516+
</div>
462517
)}
518+
{/* Iframe: always mounted so ref is stable; srcdoc set imperatively.
519+
No srcDoc React prop — prevents React from reloading the iframe
520+
on parent re-renders. */}
521+
<iframe
522+
ref={iframeRef}
523+
sandbox="allow-scripts allow-same-origin"
524+
className="w-full border-0"
525+
onLoad={() => setLoaded(true)}
526+
style={{
527+
height: ready ? height : 0,
528+
overflow: "hidden",
529+
background: "transparent",
530+
opacity: ready ? 1 : 0,
531+
transition: "opacity 300ms ease-in",
532+
display: html ? undefined : "none",
533+
}}
534+
title={title}
535+
/>
463536
</div>
464537
);
465538
}

0 commit comments

Comments
 (0)