diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 3bc1a0359..110239b03 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -4352,13 +4352,9 @@ function HtmlViewer({ [previewSource, effectiveDeck, projectId, file.name, previewStateKey, manualEditMode], ); const lazySrcDocTransport = useMemo(() => buildLazySrcdocTransport(), []); - const [hasLazySrcDocTransport, setHasLazySrcDocTransport] = useState(useUrlLoadPreview); const [srcDocTransportResetKey, setSrcDocTransportResetKey] = useState(0); const [srcDocShellReady, setSrcDocShellReady] = useState(false); const wasUrlLoadPreviewRef = useRef(useUrlLoadPreview); - useEffect(() => { - if (useUrlLoadPreview) setHasLazySrcDocTransport(true); - }, [useUrlLoadPreview]); // Reset the shell-ready latch whenever the srcDoc iframe re-mounts. The // next shell will post `od:srcdoc-transport-ready` (or fire onLoad) and // flip this back to true. See #2253. @@ -4380,7 +4376,11 @@ function HtmlViewer({ window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, []); - const useLazySrcDocTransport = !manualEditMode && (useUrlLoadPreview || hasLazySrcDocTransport); + // Lazy transport preloads an empty shell only while URL-load is the active + // transport. Once srcdoc becomes active (sandbox shim, Draw, Tweaks, etc.), + // mount the real artifact HTML directly so we do not depend on a postMessage + // activation that can race (#2253) and strand the iframe blank (#2361, #2791). + const useLazySrcDocTransport = !manualEditMode && useUrlLoadPreview; const srcDocTransportContent = useLazySrcDocTransport ? lazySrcDocTransport : srcDoc; const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank'; const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => { @@ -4438,6 +4438,10 @@ function HtmlViewer({ wasUrlLoadPreviewRef.current = true; return; } + if (wasUrlLoadPreviewRef.current) { + setSrcDocTransportResetKey((key) => key + 1); + activatedSrcDocTransportHtmlRef.current = null; + } wasUrlLoadPreviewRef.current = false; activateSrcDocTransport(); }, [activateSrcDocTransport, useUrlLoadPreview]); diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index 725ce3b59..3475cd7c0 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -454,7 +454,6 @@ describe('FileViewer SVG artifacts', () => { const srcDocFrameAfter = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null; expect(urlFrameAfter).toBe(urlFrame); - expect(srcDocFrameAfter).toBe(srcDocFrame); expect(urlFrameAfter?.getAttribute('data-od-active')).toBe('false'); expect(urlFrameAfter?.getAttribute('src')).toBe('about:blank'); expect(srcDocFrameAfter?.getAttribute('data-od-active')).toBe('true'); @@ -462,6 +461,38 @@ describe('FileViewer SVG artifacts', () => { expect(srcDocFrameAfter?.srcdoc).toContain('data-od-edit-bridge'); }); + it('renders sandbox-shim artifacts on the srcdoc transport without entering edit mode (#2791)', () => { + const file = baseFile({ + name: 'search.html', + path: 'search.html', + mime: 'text/html', + kind: 'html', + artifactManifest: { + version: 1, + kind: 'html', + title: 'Search', + entry: 'search.html', + renderer: 'html', + exports: ['html'], + }, + }); + + const { container } = render( + , + ); + + const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]') as HTMLIFrameElement | null; + expect(srcDocFrame?.getAttribute('data-od-active')).toBe('true'); + expect(srcDocFrame?.srcdoc).toContain('data-od-id="results"'); + expect(srcDocFrame?.srcdoc).not.toContain('data-od-lazy-srcdoc-transport'); + expect(srcDocFrame?.srcdoc).toContain('data-od-sandbox-shim'); + }); + it('reactivates the srcDoc transport after switching source back to preview', async () => { const file = baseFile({ name: 'page.html', @@ -1498,18 +1529,17 @@ describe('FileViewer tweaks toolbar', () => { ); expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).getAttribute('data-od-render-mode')).toBe('url-load'); - const inactiveSrcDocFrame = screen.getByTestId('artifact-preview-frame-srcdoc') as HTMLIFrameElement; - const postMessageSpy = vi.spyOn(inactiveSrcDocFrame.contentWindow!, 'postMessage'); clickAgentTool('draw-overlay-toggle'); const frame = await waitFor(() => { const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc'); - expect(activeFrame.srcdoc).toContain('data-od-lazy-srcdoc-transport'); + expect(activeFrame.srcdoc).toContain('data-od-selection-bridge'); + expect(activeFrame.srcdoc).not.toContain('data-od-lazy-srcdoc-transport'); return activeFrame; }); await waitFor(() => { - expect(srcDocActivationMessages(postMessageSpy.mock.calls).at(-1)?.html).toContain('data-od-selection-bridge'); + expect(frame.srcdoc).toContain('data-od-id="hero"'); }); expect(screen.queryByRole('button', { name: 'Click' })).toBeNull(); expect(screen.getByRole('button', { name: 'Undo' })).toBeTruthy();