Hello Sample Reader
', + post_id: 123, + } ); + + render(Test
', post_id: 42 } ); + + render(First email
', + post_id: 1, + } ); + + const { rerender } = render(Second email
', + post_id: 2, + } ); + + rerender(Second email
', + post_id: 2, + } ); + + rerender(First email (stale)
', + post_id: 1, + } ); + } ); + + // The iframe should still show the second email, not the stale first. + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' ); + expect( iframe.getAttribute( 'srcdoc' ) ).not.toContain( 'First email' ); + } ); + + // Note: The safety timeout (8s fallback for slow assets) and the iframe + // onError handler are not tested here because jsdom automatically fires + // the iframe load event when srcDoc is set, which prevents us from + // simulating pending-asset scenarios. These defensive measures work in + // real browsers but require an integration/e2e test environment. +} ); diff --git a/src/wizards/newspack/views/settings/emails/email-preview.tsx b/src/wizards/newspack/views/settings/emails/email-preview.tsx new file mode 100644 index 0000000000..c50fa4e35c --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/email-preview.tsx @@ -0,0 +1,217 @@ +/** + * EmailPreview — renders a scaled thumbnail of an email template. + * + * Lazy-loads via IntersectionObserver: the REST fetch only fires once the + * component scrolls into view. On success an iframe with srcDoc displays the + * rendered HTML; on error an envelope icon placeholder is shown instead. + * + * Rendering contract mirrors NewsletterPreview in newspack-newsletters: + * 848 px source viewport, 1 : 1 aspect ratio, fade-in via `is-ready` class, + * and iframe height measured from the loaded document. + */ + +/** + * WordPress dependencies. + */ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect, useRef, useCallback } from '@wordpress/element'; +import { Spinner } from '@wordpress/components'; +import { Icon, envelope } from '@wordpress/icons'; + +/** + * Internal dependencies. + */ +import './email-preview.scss'; + +interface EmailPreviewProps { + postId: number; +} + +const IFRAME_WIDTH = 848; + +const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => { + const containerRef = useRef< HTMLDivElement >( null ); + const iframeRef = useRef< HTMLIFrameElement >( null ); + const safetyTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + const [ isVisible, setIsVisible ] = useState( false ); + const [ html, setHtml ] = useState< string | null >( null ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ hasError, setHasError ] = useState( false ); + const [ scale, setScale ] = useState< number | null >( null ); + const [ iframeHeight, setIframeHeight ] = useState< number | null >( null ); + const [ isReady, setIsReady ] = useState( false ); + + // Observe visibility — fetch only when the thumbnail enters the viewport. + useEffect( () => { + if ( isVisible ) { + return; + } + if ( typeof IntersectionObserver === 'undefined' ) { + setIsVisible( true ); + return; + } + const node = containerRef.current; + if ( ! node ) { + return; + } + + const observer = new IntersectionObserver( + entries => { + if ( entries[ 0 ]?.isIntersecting ) { + setIsVisible( true ); + } + }, + { rootMargin: '200px' } + ); + + observer.observe( node ); + return () => observer.disconnect(); + }, [ isVisible ] ); + + // Measure container width and compute iframe scale. + useEffect( () => { + if ( typeof ResizeObserver === 'undefined' ) { + setScale( 1 ); + return; + } + const node = containerRef.current; + if ( ! node ) { + return; + } + + const ro = new ResizeObserver( ( [ entry ] ) => { + setScale( entry.contentRect.width / IFRAME_WIDTH ); + } ); + + ro.observe( node ); + return () => ro.disconnect(); + }, [] ); + + // Fetch preview HTML once visible. Cancel on postId change or unmount. + useEffect( () => { + if ( ! isVisible ) { + return; + } + + let cancelled = false; + + // Clear any lingering safety timer from a previous postId. + if ( safetyTimerRef.current ) { + clearTimeout( safetyTimerRef.current ); + safetyTimerRef.current = null; + } + + setIsLoading( true ); + setIsReady( false ); + setIframeHeight( null ); + setHasError( false ); + setHtml( null ); + apiFetch< { html: string; post_id: number } >( { + path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`, + } ) + .then( response => { + if ( ! cancelled ) { + setHtml( response.html ); + } + } ) + .catch( () => { + if ( ! cancelled ) { + setHasError( true ); + } + } ) + .finally( () => { + if ( ! cancelled ) { + setIsLoading( false ); + } + } ); + + return () => { + cancelled = true; + if ( safetyTimerRef.current ) { + clearTimeout( safetyTimerRef.current ); + safetyTimerRef.current = null; + } + }; + }, [ isVisible, postId ] ); + + // Handle iframe load: wait for stylesheets and images, then measure height and reveal. + const handleIframeLoad = useCallback( () => { + const doc = iframeRef.current?.contentDocument; + if ( ! doc ) { + return; + } + + const awaitLoad = ( el: HTMLLinkElement | HTMLImageElement ) => + new Promise< void >( resolve => { + el.addEventListener( 'load', () => resolve(), { once: true } ); + el.addEventListener( 'error', () => resolve(), { once: true } ); + } ); + + const linkPromises = Array.from( doc.querySelectorAll< HTMLLinkElement >( 'link[rel="stylesheet"]' ) ) + .filter( link => ! link.sheet ) + .map( awaitLoad ); + const imgPromises = Array.from( doc.querySelectorAll< HTMLImageElement >( 'img' ) ) + .filter( img => ! img.complete ) + .map( awaitLoad ); + + let finalized = false; + const finalize = () => { + if ( finalized ) { + return; + } + finalized = true; + if ( safetyTimerRef.current ) { + clearTimeout( safetyTimerRef.current ); + safetyTimerRef.current = null; + } + setIframeHeight( doc.body.scrollHeight ); + setIsReady( true ); + }; + + // 8 s safety so a slow asset never strands the spinner. + safetyTimerRef.current = setTimeout( finalize, 8000 ); + + Promise.all( [ ...linkPromises, ...imgPromises ] ).then( finalize ); + }, [] ); + + const showSpinner = ! hasError && ! isReady && ( isLoading || Boolean( html ) ); + + return ( +