Skip to content

Commit ae4d00e

Browse files
kmwilkersonclaude
andcommitted
refactor(email-preview): align with NewsletterPreview rendering contract (NPPD-1525)
Address thomasguillot review feedback: 848px source viewport, 1:1 aspect ratio, fade-in via is-ready class with opacity transition, iframe height measured from contentDocument after stylesheets/images load (8s safety timeout), state reset on postId change. Adds tests for is-ready class and reset-on-postId-change behaviour. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cf13c9f commit ae4d00e

3 files changed

Lines changed: 135 additions & 12 deletions

File tree

src/wizards/newspack/views/settings/emails/email-preview.scss

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
// Email preview thumbnail container.
22
.newspack-email-preview {
33
width: 100%;
4-
aspect-ratio: 4 / 3;
4+
aspect-ratio: 1;
55
overflow: hidden;
66
position: relative;
7-
background: #f6f7f7;
7+
background: transparent;
88
display: flex;
99
align-items: center;
1010
justify-content: center;
1111

1212
&__iframe {
13-
width: 600px;
14-
height: 1800px;
13+
width: 848px;
14+
height: auto;
1515
border: 0;
1616
position: absolute;
1717
top: 0;
1818
left: 0;
1919
transform-origin: top left;
2020
pointer-events: none;
21+
opacity: 0;
22+
transition: opacity 200ms ease-out;
23+
}
24+
25+
&.is-ready &__iframe {
26+
opacity: 1;
2127
}
2228

2329
// Loading / error placeholder overlay.

src/wizards/newspack/views/settings/emails/email-preview.test.js

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ global.ResizeObserver = class {
5050
disconnect() {}
5151
};
5252

53+
/**
54+
* Helper: simulate iframe onLoad and stub contentDocument so
55+
* handleIframeLoad resolves immediately (no pending assets).
56+
*/
57+
function simulateIframeLoad( iframe ) {
58+
Object.defineProperty( iframe, 'contentDocument', {
59+
value: {
60+
querySelectorAll: () => [],
61+
body: { scrollHeight: 900 },
62+
},
63+
configurable: true,
64+
} );
65+
iframe.dispatchEvent( new Event( 'load' ) );
66+
}
67+
5368
describe( 'EmailPreview', () => {
5469
beforeEach( () => {
5570
apiFetch.mockReset();
@@ -65,7 +80,7 @@ describe( 'EmailPreview', () => {
6580
expect( screen.getByRole( 'presentation' ) ).toBeTruthy();
6681
} );
6782

68-
it( 'renders iframe on successful fetch', async () => {
83+
it( 'renders iframe on successful fetch and gains is-ready after load', async () => {
6984
apiFetch.mockResolvedValue( {
7085
html: '<html><body><p>Hello Sample Reader</p></body></html>',
7186
post_id: 123,
@@ -78,6 +93,18 @@ describe( 'EmailPreview', () => {
7893
expect( iframe ).toBeTruthy();
7994
expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Sample Reader' );
8095
} );
96+
97+
// Before onLoad the container should NOT have is-ready.
98+
const container = document.querySelector( '.newspack-email-preview' );
99+
expect( container.classList.contains( 'is-ready' ) ).toBe( false );
100+
101+
// Simulate iframe load.
102+
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
103+
simulateIframeLoad( iframe );
104+
105+
await waitFor( () => {
106+
expect( container.classList.contains( 'is-ready' ) ).toBe( true );
107+
} );
81108
} );
82109

83110
it( 'renders fallback placeholder on fetch error', async () => {
@@ -113,4 +140,45 @@ describe( 'EmailPreview', () => {
113140
} );
114141
} );
115142
} );
143+
144+
it( 'resets state when postId changes', async () => {
145+
apiFetch.mockResolvedValue( {
146+
html: '<html><body><p>First email</p></body></html>',
147+
post_id: 1,
148+
} );
149+
150+
const { rerender } = render( <EmailPreview postId={ 1 } /> );
151+
152+
// Wait for first render to complete.
153+
await waitFor( () => {
154+
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
155+
expect( iframe ).toBeTruthy();
156+
} );
157+
158+
// Simulate onLoad for first email.
159+
simulateIframeLoad( document.querySelector( '.newspack-email-preview__iframe' ) );
160+
await waitFor( () => {
161+
expect( document.querySelector( '.newspack-email-preview' ).classList.contains( 'is-ready' ) ).toBe( true );
162+
} );
163+
164+
// Change postId — should reset and re-fetch.
165+
apiFetch.mockResolvedValue( {
166+
html: '<html><body><p>Second email</p></body></html>',
167+
post_id: 2,
168+
} );
169+
170+
rerender( <EmailPreview postId={ 2 } /> );
171+
172+
// is-ready should be removed during re-fetch.
173+
await waitFor( () => {
174+
expect( document.querySelector( '.newspack-email-preview' ).classList.contains( 'is-ready' ) ).toBe( false );
175+
} );
176+
177+
// New iframe should appear with updated content.
178+
await waitFor( () => {
179+
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
180+
expect( iframe ).toBeTruthy();
181+
expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' );
182+
} );
183+
} );
116184
} );

src/wizards/newspack/views/settings/emails/email-preview.tsx

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
* Lazy-loads via IntersectionObserver: the REST fetch only fires once the
55
* component scrolls into view. On success an iframe with srcDoc displays the
66
* rendered HTML; on error an envelope icon placeholder is shown instead.
7+
*
8+
* Rendering contract mirrors NewsletterPreview in newspack-newsletters:
9+
* 848 px source viewport, 1 : 1 aspect ratio, fade-in via `is-ready` class,
10+
* and iframe height measured from the loaded document.
711
*/
812

913
/**
1014
* WordPress dependencies.
1115
*/
1216
import apiFetch from '@wordpress/api-fetch';
13-
import { useState, useEffect, useRef } from '@wordpress/element';
17+
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
1418
import { Spinner } from '@wordpress/components';
1519
import { Icon, envelope } from '@wordpress/icons';
1620

@@ -23,15 +27,18 @@ interface EmailPreviewProps {
2327
postId: number;
2428
}
2529

26-
const IFRAME_WIDTH = 600;
30+
const IFRAME_WIDTH = 848;
2731

2832
const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
2933
const containerRef = useRef< HTMLDivElement >( null );
34+
const iframeRef = useRef< HTMLIFrameElement >( null );
3035
const [ isVisible, setIsVisible ] = useState( false );
3136
const [ html, setHtml ] = useState< string | null >( null );
3237
const [ isLoading, setIsLoading ] = useState( false );
3338
const [ hasError, setHasError ] = useState( false );
3439
const [ scale, setScale ] = useState( 0 );
40+
const [ iframeHeight, setIframeHeight ] = useState< number | null >( null );
41+
const [ isReady, setIsReady ] = useState( false );
3542

3643
// Observe visibility — fetch only when the thumbnail enters the viewport.
3744
useEffect( () => {
@@ -79,13 +86,17 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
7986
return () => ro.disconnect();
8087
}, [] );
8188

82-
// Fetch preview HTML once visible.
89+
// Fetch preview HTML once visible. Reset state on postId change.
8390
useEffect( () => {
8491
if ( ! isVisible ) {
8592
return;
8693
}
8794

8895
setIsLoading( true );
96+
setIsReady( false );
97+
setIframeHeight( null );
98+
setHasError( false );
99+
setHtml( null );
89100
apiFetch< { html: string; post_id: number } >( {
90101
path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`,
91102
} )
@@ -100,9 +111,42 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
100111
} );
101112
}, [ isVisible, postId ] );
102113

114+
// Handle iframe load: wait for stylesheets and images, then measure height and reveal.
115+
const handleIframeLoad = useCallback( () => {
116+
const doc = iframeRef.current?.contentDocument;
117+
if ( ! doc ) {
118+
return;
119+
}
120+
121+
const awaitLoad = ( el: HTMLLinkElement | HTMLImageElement ) =>
122+
new Promise< void >( resolve => {
123+
el.addEventListener( 'load', () => resolve(), { once: true } );
124+
el.addEventListener( 'error', () => resolve(), { once: true } );
125+
} );
126+
127+
const linkPromises = Array.from( doc.querySelectorAll< HTMLLinkElement >( 'link[rel="stylesheet"]' ) )
128+
.filter( link => ! link.sheet )
129+
.map( awaitLoad );
130+
const imgPromises = Array.from( doc.querySelectorAll< HTMLImageElement >( 'img' ) )
131+
.filter( img => ! img.complete )
132+
.map( awaitLoad );
133+
134+
// 8 s safety so a slow asset never strands the spinner.
135+
const safety = setTimeout( () => {
136+
setIframeHeight( doc.body.scrollHeight );
137+
setIsReady( true );
138+
}, 8000 );
139+
140+
Promise.all( [ ...linkPromises, ...imgPromises ] ).then( () => {
141+
clearTimeout( safety );
142+
setIframeHeight( doc.body.scrollHeight );
143+
setIsReady( true );
144+
} );
145+
}, [] );
146+
103147
return (
104-
<div ref={ containerRef } className="newspack-email-preview">
105-
{ isLoading && (
148+
<div ref={ containerRef } className={ `newspack-email-preview${ isReady ? ' is-ready' : '' }` }>
149+
{ ( isLoading || ( html && ! isReady ) ) && ! hasError && (
106150
<div className="newspack-email-preview__placeholder">
107151
<Spinner />
108152
</div>
@@ -112,14 +156,19 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
112156
<Icon icon={ envelope } size={ 48 } />
113157
</div>
114158
) }
115-
{ html && ! hasError && ! isLoading && scale > 0 && (
159+
{ html && ! hasError && scale > 0 && (
116160
<iframe
161+
ref={ iframeRef }
117162
className="newspack-email-preview__iframe"
118163
srcDoc={ html }
119164
sandbox=""
120165
tabIndex={ -1 }
121166
title="Email preview"
122-
style={ { transform: `scale(${ scale })` } }
167+
onLoad={ handleIframeLoad }
168+
style={ {
169+
transform: `scale(${ scale })`,
170+
height: iframeHeight ? `${ iframeHeight }px` : undefined,
171+
} }
123172
/>
124173
) }
125174
</div>

0 commit comments

Comments
 (0)