Skip to content

Commit 92e9df3

Browse files
committed
fix: improve widget renderer loading - skeleton, scoped resize, no jitter
- Show skeleton placeholder until iframe is fully loaded with valid height - Scope postMessage handler to own iframe (prevent cross-widget contamination) - Use #content offsetHeight instead of scrollHeight for accurate sizing - Remove height transition to eliminate scroll jitter from ResizeObserver - Add progressive reveal CSS for staggered fade-in of SVG/HTML content - Fade in iframe with opacity transition only once ready
1 parent 50d90b3 commit 92e9df3

1 file changed

Lines changed: 66 additions & 17 deletions

File tree

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

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,22 @@ input[type="checkbox"], input[type="radio"] {
301301
a { color: var(--color-text-info); text-decoration: none; }
302302
a:hover { text-decoration: underline; }
303303
304+
/* Progressive reveal for content children */
305+
#content > * {
306+
animation: fadeSlideIn 0.4s ease-out both;
307+
}
308+
#content > *:nth-child(1) { animation-delay: 0s; }
309+
#content > *:nth-child(2) { animation-delay: 0.06s; }
310+
#content > *:nth-child(3) { animation-delay: 0.12s; }
311+
#content > *:nth-child(4) { animation-delay: 0.18s; }
312+
#content > *:nth-child(5) { animation-delay: 0.24s; }
313+
#content > *:nth-child(n+6) { animation-delay: 0.3s; }
314+
315+
@keyframes fadeSlideIn {
316+
from { opacity: 0; transform: translateY(8px); }
317+
to { opacity: 1; transform: translateY(0); }
318+
}
319+
304320
@media (prefers-reduced-motion: reduce) {
305321
*, *::before, *::after {
306322
animation-duration: 0.01ms !important;
@@ -332,11 +348,12 @@ document.addEventListener('click', function(e) {
332348
333349
// Auto-resize: report content height to host
334350
function reportHeight() {
335-
var h = document.documentElement.scrollHeight;
351+
var content = document.getElementById('content');
352+
var h = content ? content.offsetHeight : document.documentElement.scrollHeight;
336353
window.parent.postMessage({ type: 'widget-resize', height: h }, '*');
337354
}
338355
var ro = new ResizeObserver(reportHeight);
339-
ro.observe(document.body);
356+
ro.observe(document.getElementById('content') || document.body);
340357
window.addEventListener('load', reportHeight);
341358
// Periodic reports during initial load
342359
var _resizeInterval = setInterval(reportHeight, 200);
@@ -382,11 +399,18 @@ function assembleDocument(html: string): string {
382399
// ─── React Component ─────────────────────────────────────────────────
383400
export function WidgetRenderer({ title, description, html }: WidgetRendererProps) {
384401
const iframeRef = useRef<HTMLIFrameElement>(null);
385-
const [height, setHeight] = useState(500);
402+
const [height, setHeight] = useState(0);
403+
const [loaded, setLoaded] = useState(false);
386404

387405
const handleMessage = useCallback((e: MessageEvent) => {
388-
if (e.data?.type === "widget-resize" && typeof e.data.height === "number") {
389-
setHeight(Math.max(100, Math.min(e.data.height + 8, 4000)));
406+
// Only handle messages from our own iframe
407+
if (
408+
iframeRef.current &&
409+
e.source === iframeRef.current.contentWindow &&
410+
e.data?.type === "widget-resize" &&
411+
typeof e.data.height === "number"
412+
) {
413+
setHeight(Math.max(50, Math.min(e.data.height + 8, 4000)));
390414
}
391415
}, []);
392416

@@ -395,22 +419,47 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
395419
return () => window.removeEventListener("message", handleMessage);
396420
}, [handleMessage]);
397421

398-
if (!html) {
399-
return null;
400-
}
422+
// Reset loaded state when html changes
423+
useEffect(() => {
424+
setLoaded(false);
425+
setHeight(0);
426+
}, [html]);
427+
428+
// Iframe is ready when it has loaded AND reported a valid height
429+
const ready = loaded && height > 0;
401430

402-
const srcdoc = assembleDocument(html);
431+
const srcdoc = html ? assembleDocument(html) : "";
403432

404433
return (
405434
<div className="w-full my-3">
406-
<iframe
407-
ref={iframeRef}
408-
srcDoc={srcdoc}
409-
sandbox="allow-scripts allow-same-origin"
410-
className="w-full border-0"
411-
style={{ height, overflow: "hidden", background: "transparent" }}
412-
title={title}
413-
/>
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)}
453+
style={{
454+
height: ready ? height : 0,
455+
overflow: "hidden",
456+
background: "transparent",
457+
opacity: ready ? 1 : 0,
458+
transition: "opacity 300ms ease-in",
459+
}}
460+
title={title}
461+
/>
462+
)}
414463
</div>
415464
);
416465
}

0 commit comments

Comments
 (0)