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:
parent
6a4d36878e
commit
5b6d8fb7a8
@ -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}/>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
|
65
apps/comments-ui/test/e2e/lazy-loading.test.ts
Normal file
65
apps/comments-ui/test/e2e/lazy-loading.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user