From a6d86713060a7f3b7b1a3b8ab011084d7fe3fb34 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:08:14 +0200 Subject: [PATCH] Fix: render email body on DOM parse, not iframe load #635 --- components/email/email-viewer.tsx | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 88ff7781..8d179a3f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2190,8 +2190,20 @@ export function EmailViewer({ // Gates the quick reply on the iframe having loaded the current srcDoc, so // it doesn't flash in below a still-resizing iframe. const [iframeReady, setIframeReady] = useState(false); + // Tracks which parsed document we've already wired up, so setup runs exactly + // once per srcDoc even though both the readiness poll below and the iframe + // 'load' event can trigger it. + const initializedDocRef = useRef(null); + // The document present at the instant srcDoc changed - i.e. the one about to + // be torn down. contentDocument keeps pointing at it until the browser swaps + // the new srcDoc in, so the poll skips it to avoid wiring up stale content. + const staleDocRef = useRef(null); useLayoutEffect(() => { setIframeReady(false); + initializedDocRef.current = null; + // Runs during commit, before the browser processes the new srcDoc, so + // contentDocument here is still the outgoing document. + staleDocRef.current = iframeRef.current?.contentDocument ?? null; }, [emailIframeSrcDoc]); const handleIframeLoad = useCallback(() => { @@ -2199,7 +2211,13 @@ export function EmailViewer({ if (!iframe) return; try { const doc = iframe.contentDocument; - if (doc?.body) { + // Ignore the outgoing document, a transient about:blank (a fresh srcDoc + // document reports URL 'about:srcdoc'), and anything that hasn't finished + // parsing yet; run the setup below at most once per document. + if (!doc?.body || doc === staleDocRef.current || doc.URL !== 'about:srcdoc' || doc.readyState === 'loading') return; + if (initializedDocRef.current === doc) return; + initializedDocRef.current = doc; + { // Auto-resize iframe to fit content // Measure max(documentElement, body): a height:100% wrapper can leave // documentElement.scrollHeight short while the real content lives in body. @@ -2375,6 +2393,26 @@ export function EmailViewer({ } }, [isDark, emailHasNativeDarkMode, email?.id]); + // Wire up the iframe as soon as its sandboxed document has parsed, rather than + // waiting for the iframe 'load' event. 'load' also waits on every subresource, + // so a single unreachable remote image (server accepts the TCP connection but + // never responds) stalls it for the browser's ~60s timeout - freezing the body + // at its placeholder height that entire time. The parsed DOM we need for + // height, links and dark-mode is ready long before images resolve. Poll the + // fresh document's readyState because a sandbox without allow-scripts can't + // postMessage a DOMContentLoaded signal out, and the onLoad handler is + // idempotent per document so it stays a harmless backstop. + useEffect(() => { + if (!iframeRef.current) return; + const readyPoll = window.setInterval(() => { + handleIframeLoad(); + if (initializedDocRef.current) window.clearInterval(readyPoll); + }, 50); + // Safety stop: the 'load' backstop covers anything the poll somehow misses. + const stop = window.setTimeout(() => window.clearInterval(readyPoll), 15000); + return () => { window.clearInterval(readyPoll); window.clearTimeout(stop); }; + }, [emailIframeSrcDoc, handleIframeLoad]); + // Export email as .eml file const handleExportEmail = async () => { if (!email?.blobId || !client) return;