@@ -349,20 +349,32 @@ document.addEventListener('click', function(e) {
349349
350350// Listen for streaming content updates from parent
351351window.addEventListener('message', function(e) {
352+ if (e.source !== window.parent) return;
352353 if (e.data && e.data.type === 'update-content') {
353354 var content = document.getElementById('content');
354355 if (content) {
355- content.innerHTML = e.data.html;
356- // Re-run any inline scripts (new ones added by streaming)
357- var scripts = content.querySelectorAll('script');
358- scripts.forEach(function(oldScript) {
356+ // Strip script tags from HTML before inserting — scripts are handled separately below
357+ var tmp = document.createElement('div');
358+ tmp.innerHTML = e.data.html;
359+ var incomingScripts = [];
360+ tmp.querySelectorAll('script').forEach(function(s) {
361+ incomingScripts.push({ src: s.src, text: s.textContent });
362+ s.remove();
363+ });
364+ content.innerHTML = tmp.innerHTML;
365+
366+ // Execute only new scripts (not previously executed)
367+ incomingScripts.forEach(function(scriptInfo) {
368+ var key = scriptInfo.src || scriptInfo.text;
369+ if (content.getAttribute('data-exec-' + btoa(key).slice(0, 16))) return;
370+ content.setAttribute('data-exec-' + btoa(key).slice(0, 16), '1');
359371 var newScript = document.createElement('script');
360- if (oldScript .src) {
361- newScript.src = oldScript .src;
372+ if (scriptInfo .src) {
373+ newScript.src = scriptInfo .src;
362374 } else {
363- newScript.textContent = oldScript.textContent ;
375+ newScript.textContent = scriptInfo.text ;
364376 }
365- oldScript.parentNode.replaceChild (newScript, oldScript );
377+ content.appendChild (newScript);
366378 });
367379 reportHeight();
368380 }
@@ -384,11 +396,6 @@ setTimeout(function() { clearInterval(_resizeInterval); }, 15000);
384396` ;
385397
386398// ─── Document Assembly ───────────────────────────────────────────────
387- /** Full document with content — used for final/complete renders */
388- function assembleDocument ( html : string ) : string {
389- return assembleShell ( html ) ;
390- }
391-
392399/** Empty shell or shell with initial content — iframe loads once, content streamed via postMessage */
393400function assembleShell ( initialHtml : string = "" ) : string {
394401 return `<!DOCTYPE html>
@@ -440,7 +447,6 @@ function useLoadingPhrase(active: boolean) {
440447 const [ index , setIndex ] = useState ( 0 ) ;
441448 useEffect ( ( ) => {
442449 if ( ! active ) return ;
443- setIndex ( 0 ) ;
444450 const interval = setInterval ( ( ) => {
445451 setIndex ( ( i ) => ( i + 1 ) % LOADING_PHRASES . length ) ;
446452 } , 1800 ) ;
@@ -459,11 +465,12 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
459465 const shellReadyRef = useRef ( false ) ;
460466 // Track the last html sent to the iframe to avoid redundant updates
461467 const committedHtmlRef = useRef ( "" ) ;
462- // Whether streaming has started (html is arriving but may not be complete)
463- const [ streaming , setStreaming ] = useState ( false ) ;
464468 // Tracks whether html content has settled (stopped changing)
465469 const [ htmlSettled , setHtmlSettled ] = useState ( false ) ;
470+ const [ prevHtml , setPrevHtml ] = useState ( html ) ;
466471 const settledTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
472+ const fadeTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
473+ const [ fadingOut , setFadingOut ] = useState ( false ) ;
467474
468475 const handleMessage = useCallback ( ( e : MessageEvent ) => {
469476 // Only handle messages from our own iframe
@@ -482,6 +489,13 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
482489 return ( ) => window . removeEventListener ( "message" , handleMessage ) ;
483490 } , [ handleMessage ] ) ;
484491
492+ // Reset settled/fade state when html changes (adjust state during render)
493+ if ( html !== prevHtml ) {
494+ setPrevHtml ( html ) ;
495+ setHtmlSettled ( false ) ;
496+ setFadingOut ( false ) ;
497+ }
498+
485499 // Initialize the iframe shell once when html first appears.
486500 // After that, stream content updates via postMessage — no iframe reload.
487501 useEffect ( ( ) => {
@@ -492,7 +506,6 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
492506 shellReadyRef . current = true ;
493507 committedHtmlRef . current = html ;
494508 iframeRef . current . srcdoc = assembleShell ( html ) ;
495- setStreaming ( true ) ;
496509 return ;
497510 }
498511
@@ -512,15 +525,19 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
512525 // Detect when html has stopped changing (streaming complete).
513526 // Resets a debounce timer on every html update — settles after 800ms of no changes.
514527 useEffect ( ( ) => {
515- if ( ! html ) {
516- setHtmlSettled ( false ) ;
517- return ;
518- }
519- setHtmlSettled ( false ) ;
528+ if ( ! html ) return ;
520529 if ( settledTimerRef . current ) clearTimeout ( settledTimerRef . current ) ;
521- settledTimerRef . current = setTimeout ( ( ) => setHtmlSettled ( true ) , 800 ) ;
530+ if ( fadeTimerRef . current ) clearTimeout ( fadeTimerRef . current ) ;
531+ settledTimerRef . current = setTimeout ( ( ) => {
532+ setHtmlSettled ( true ) ;
533+ setFadingOut ( true ) ;
534+ fadeTimerRef . current = setTimeout ( ( ) => {
535+ setFadingOut ( false ) ;
536+ } , 600 ) ;
537+ } , 800 ) ;
522538 return ( ) => {
523539 if ( settledTimerRef . current ) clearTimeout ( settledTimerRef . current ) ;
540+ if ( fadeTimerRef . current ) clearTimeout ( fadeTimerRef . current ) ;
524541 } ;
525542 } , [ html ] ) ;
526543
@@ -534,25 +551,12 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
534551 return ( ) => clearTimeout ( timeout ) ;
535552 } , [ html , loaded , height ] ) ;
536553
537- // Content is "complete" when iframe has loaded and reported a valid height
538- const ready = loaded && height > 0 ;
539- // Show the iframe (even partially) as soon as we have content streaming
540- const showIframe = ! ! html && ( streaming || ready ) ;
554+ // Show the iframe as soon as we have html (shell initializes on first html)
555+ const showIframe = ! ! html ;
541556 // Streaming is active until html has stopped changing
542557 const isStreaming = ! ! html && ! htmlSettled ;
543558 const loadingPhrase = useLoadingPhrase ( isStreaming ) ;
544-
545- // Keep the streaming indicator mounted long enough to fade out
546- const [ showStreamingIndicator , setShowStreamingIndicator ] = useState ( false ) ;
547- useEffect ( ( ) => {
548- if ( isStreaming ) {
549- setShowStreamingIndicator ( true ) ;
550- } else if ( showStreamingIndicator ) {
551- // Keep mounted for fade-out, then unmount
552- const timeout = setTimeout ( ( ) => setShowStreamingIndicator ( false ) , 600 ) ;
553- return ( ) => clearTimeout ( timeout ) ;
554- }
555- } , [ isStreaming , showStreamingIndicator ] ) ;
559+ const showStreamingIndicator = isStreaming || fadingOut ;
556560
557561 return (
558562 < div className = "w-full my-3" >
@@ -599,7 +603,7 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
599603 content streamed via postMessage for progressive rendering. */ }
600604 < iframe
601605 ref = { iframeRef }
602- sandbox = "allow-scripts allow-same-origin "
606+ sandbox = "allow-scripts"
603607 className = "w-full border-0"
604608 onLoad = { ( ) => setLoaded ( true ) }
605609 style = { {
0 commit comments