Added lazy-loading to comments (#19769)

closes ENG-678

The comments block is typically shown at the bottom of a post so it doesn't make sense to eagerly fetch comments from the API when we don't know if the comments block will even be viewed. By lazy-loading the data only when the comments block comes into view we can reduce both data usage for visitors and load on the site.

- uses IntersectionObserver API to delay comments app initialisation until the comments block has scrolled into view
- updated all iframe-related components to forward a `ref` so we can use the `<iframe>` element reference inside the `App` component
This commit is contained in:
Kevin Ansfield 2024-02-28 12:52:24 +00:00 committed by GitHub
parent 6a4d36878e
commit 5b6d8fb7a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 132 additions and 19 deletions

View File

@ -29,6 +29,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
popup: null
});
const iframeRef = React.createRef<HTMLIFrameElement>();
const api = React.useMemo(() => {
return setupGhostApi({
siteUrl: options.siteUrl,
@ -129,7 +131,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
};
};
/** Initialize comments setup on load, fetch data and setup state*/
/** Initialize comments setup once in viewport, fetch data and setup state*/
const initSetup = async () => {
try {
// Fetch data from API, links, preview, dev sources
@ -155,15 +157,39 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
}
};
/** Delay initialization until comments block is in viewport */
useEffect(() => {
initSetup();
}, []);
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
initSetup();
if (iframeRef.current) {
observer.unobserve(iframeRef.current);
}
}
});
}, {
root: null,
rootMargin: '0px',
threshold: 0.1
});
if (iframeRef.current) {
observer.observe(iframeRef.current);
}
return () => {
if (iframeRef.current) {
observer.unobserve(iframeRef.current);
}
};
}, [iframeRef.current]);
const done = state.initStatus === 'success';
return (
<AppContext.Provider value={context}>
<CommentsFrame>
<CommentsFrame ref={iframeRef}>
<ContentBox done={done} />
</CommentsFrame>
<AuthFrame adminUrl={options.adminUrl} onLoad={initAdminAuth}/>

View File

@ -54,7 +54,7 @@ const ContentBox: React.FC<Props> = ({done}) => {
};
return (
<section className={'ghost-display ' + containerClass} data-testid="content-box" style={style}>
<section className={'ghost-display ' + containerClass} data-loaded={done} data-testid="content-box" style={style}>
{done ? <Content /> : <Loading />}
</section>
);

View File

@ -15,7 +15,7 @@ type TailwindFrameProps = FrameProps & {
/**
* Loads all the CSS styles inside an iFrame. Only shows the visible content as soon as the CSS file with the tailwind classes has loaded.
*/
const TailwindFrame: React.FC<TailwindFrameProps> = ({children, onResize, style, title}) => {
const TailwindFrame = React.forwardRef<HTMLIFrameElement, React.PropsWithChildren<TailwindFrameProps>>(function TailwindFrame({children, onResize, style, title}, ref: React.ForwardedRef<HTMLIFrameElement>) {
const head = (
<>
<style dangerouslySetInnerHTML={{__html: styles}} />
@ -25,11 +25,11 @@ const TailwindFrame: React.FC<TailwindFrameProps> = ({children, onResize, style,
// For now we're using <NewFrame> because using a functional component with portal caused some weird issues with modals
return (
<IFrame head={head} style={style} title={title} onResize={onResize}>
<IFrame ref={ref} head={head} style={style} title={title} onResize={onResize}>
{children}
</IFrame>
);
};
});
type ResizableFrameProps = FrameProps & {
style: React.CSSProperties,
@ -39,7 +39,7 @@ type ResizableFrameProps = FrameProps & {
/**
* This iframe has the same height as it contents and mimics a shadow DOM component
*/
const ResizableFrame: React.FC<ResizableFrameProps> = ({children, style, title}) => {
const ResizableFrame = React.forwardRef<HTMLIFrameElement, React.PropsWithChildren<ResizableFrameProps>>(function ResizableFrame({children, style, title}, ref: React.ForwardedRef<HTMLIFrameElement>) {
const [iframeStyle, setIframeStyle] = useState(style);
const onResize = useCallback((iframeRoot) => {
setIframeStyle((current) => {
@ -51,23 +51,25 @@ const ResizableFrame: React.FC<ResizableFrameProps> = ({children, style, title})
}, []);
return (
<TailwindFrame style={iframeStyle} title={title} onResize={onResize}>
<TailwindFrame ref={ref} style={iframeStyle} title={title} onResize={onResize}>
{children}
</TailwindFrame>
);
};
});
export const CommentsFrame: React.FC<FrameProps> = ({children}) => {
type CommentsFrameProps = Record<never, any>;
export const CommentsFrame = React.forwardRef<HTMLIFrameElement, React.PropsWithChildren<CommentsFrameProps>>(function CommentsFrame({children}, ref: React.ForwardedRef<HTMLIFrameElement>) {
const style = {
width: '100%',
height: '400px'
};
return (
<ResizableFrame style={style} title="comments-frame">
<ResizableFrame ref={ref} style={style} title="comments-frame">
{children}
</ResizableFrame>
);
};
});
type PopupFrameProps = FrameProps & {
title: string

View File

@ -1,10 +1,10 @@
import {Component} from 'react';
import {Component, forwardRef} from 'react';
import {createPortal} from 'react-dom';
/**
* This is still a class component because it causes issues with the behaviour (DOM recreation and layout glitches) if we switch to a functional component. Feel free to refactor.
*/
export default class IFrame extends Component<any> {
class IFrame extends Component<any> {
node: any;
iframeHtml: any;
iframeHead: any;
@ -59,6 +59,7 @@ export default class IFrame extends Component<any> {
setNode(node: any) {
this.node = node;
this.props.innerRef.current = node;
}
render() {
@ -71,3 +72,9 @@ export default class IFrame extends Component<any> {
);
}
}
const IFrameFC = forwardRef<HTMLIFrameElement, any>(function IFrameFC(props, ref) {
return <IFrame {...props} innerRef={ref} />;
});
export default IFrameFC;

View File

@ -2,7 +2,7 @@ import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
function Loading() {
return (
<div className="flex h-32 w-full items-center justify-center">
<div className="flex h-32 w-full items-center justify-center" data-testid="loading">
<SpinnerIcon className="mb-6 h-12 w-12 fill-[rgb(225,225,225,0.9)] dark:fill-[rgba(255,255,255,0.6)]" />
</div>
);

View File

@ -0,0 +1,65 @@
import {E2E_PORT} from '../../playwright.config';
import {MOCKED_SITE_URL, MockedApi} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('Lazy loading', async () => {
test('delays loading of content until scrolled into view', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.setMember({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
const sitePath = MOCKED_SITE_URL;
await page.route(sitePath, async (route) => {
await route.fulfill({
status: 200,
// include a div at the top of the body that's 1.5x viewport height
// to force the need to scroll to see the comments
body: `<html><head><meta charset="UTF-8" /></head><body><div style="width: 100%; height: 1500px;"></div></body></html>`
});
});
const url = `http://localhost:${E2E_PORT}/comments-ui.min.js`;
await page.setViewportSize({width: 1000, height: 1000});
await page.goto(sitePath);
await mockedApi.listen({page, path: sitePath});
const options = {
publication: 'Publisher Weekly',
count: true,
title: 'Title',
ghostComments: MOCKED_SITE_URL,
postId: mockedApi.postId
};
await page.evaluate((data) => {
const scriptTag = document.createElement('script');
scriptTag.src = data.url;
for (const option of Object.keys(data.options)) {
scriptTag.dataset[option] = data.options[option];
}
document.body.appendChild(scriptTag);
}, {url, options});
await page.locator('iframe[title="comments-frame"]').waitFor({state: 'attached'});
const commentsFrameSelector = 'iframe[title="comments-frame"]';
const frame = page.frameLocator(commentsFrameSelector);
// wait for a little bit to ensure we're not loading comments until scrolled
await page.waitForTimeout(250);
// check that we haven't loaded comments yet
await expect(frame.getByTestId('loading')).toHaveCount(1);
const iframeHandle = await page.locator(commentsFrameSelector);
iframeHandle.scrollIntoViewIfNeeded();
await expect(frame.getByTestId('loading')).toHaveCount(0);
});
});

View File

@ -31,7 +31,7 @@ function authFrameMain() {
}
if (!d) {
return
return;
}
const data: {uid: string, action: string} = d;
@ -126,10 +126,23 @@ export async function initialize({mockedApi, page, bodyStyle, ...options}: {
document.body.appendChild(scriptTag);
}, {url, options});
const commentsFrameSelector = 'iframe[title="comments-frame"]';
await page.waitForSelector('iframe');
// wait for data to be loaded because our tests expect it
const iframeElement = await page.locator(commentsFrameSelector).elementHandle();
if (!iframeElement) {
throw new Error('iframe not found');
}
const iframe = await iframeElement.contentFrame();
if (!iframe) {
throw new Error('iframe contentFrame not found');
}
await iframe.waitForSelector('[data-loaded="true"]');
return {
frame: page.frameLocator('iframe[title="comments-frame"]')
frame: page.frameLocator(commentsFrameSelector)
};
}