mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
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
7 changed files with 132 additions and 19 deletions
|
@ -29,6 +29,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||||
popup: null
|
popup: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const iframeRef = React.createRef<HTMLIFrameElement>();
|
||||||
|
|
||||||
const api = React.useMemo(() => {
|
const api = React.useMemo(() => {
|
||||||
return setupGhostApi({
|
return setupGhostApi({
|
||||||
siteUrl: options.siteUrl,
|
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 () => {
|
const initSetup = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch data from API, links, preview, dev sources
|
// 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(() => {
|
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';
|
const done = state.initStatus === 'success';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={context}>
|
<AppContext.Provider value={context}>
|
||||||
<CommentsFrame>
|
<CommentsFrame ref={iframeRef}>
|
||||||
<ContentBox done={done} />
|
<ContentBox done={done} />
|
||||||
</CommentsFrame>
|
</CommentsFrame>
|
||||||
<AuthFrame adminUrl={options.adminUrl} onLoad={initAdminAuth}/>
|
<AuthFrame adminUrl={options.adminUrl} onLoad={initAdminAuth}/>
|
||||||
|
|
|
@ -54,7 +54,7 @@ const ContentBox: React.FC<Props> = ({done}) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 />}
|
{done ? <Content /> : <Loading />}
|
||||||
</section>
|
</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.
|
* 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 = (
|
const head = (
|
||||||
<>
|
<>
|
||||||
<style dangerouslySetInnerHTML={{__html: styles}} />
|
<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
|
// For now we're using <NewFrame> because using a functional component with portal caused some weird issues with modals
|
||||||
return (
|
return (
|
||||||
<IFrame head={head} style={style} title={title} onResize={onResize}>
|
<IFrame ref={ref} head={head} style={style} title={title} onResize={onResize}>
|
||||||
{children}
|
{children}
|
||||||
</IFrame>
|
</IFrame>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
type ResizableFrameProps = FrameProps & {
|
type ResizableFrameProps = FrameProps & {
|
||||||
style: React.CSSProperties,
|
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
|
* 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 [iframeStyle, setIframeStyle] = useState(style);
|
||||||
const onResize = useCallback((iframeRoot) => {
|
const onResize = useCallback((iframeRoot) => {
|
||||||
setIframeStyle((current) => {
|
setIframeStyle((current) => {
|
||||||
|
@ -51,23 +51,25 @@ const ResizableFrame: React.FC<ResizableFrameProps> = ({children, style, title})
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TailwindFrame style={iframeStyle} title={title} onResize={onResize}>
|
<TailwindFrame ref={ref} style={iframeStyle} title={title} onResize={onResize}>
|
||||||
{children}
|
{children}
|
||||||
</TailwindFrame>
|
</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 = {
|
const style = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '400px'
|
height: '400px'
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ResizableFrame style={style} title="comments-frame">
|
<ResizableFrame ref={ref} style={style} title="comments-frame">
|
||||||
{children}
|
{children}
|
||||||
</ResizableFrame>
|
</ResizableFrame>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
type PopupFrameProps = FrameProps & {
|
type PopupFrameProps = FrameProps & {
|
||||||
title: string
|
title: string
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import {Component} from 'react';
|
import {Component, forwardRef} from 'react';
|
||||||
import {createPortal} from 'react-dom';
|
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.
|
* 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;
|
node: any;
|
||||||
iframeHtml: any;
|
iframeHtml: any;
|
||||||
iframeHead: any;
|
iframeHead: any;
|
||||||
|
@ -59,6 +59,7 @@ export default class IFrame extends Component<any> {
|
||||||
|
|
||||||
setNode(node: any) {
|
setNode(node: any) {
|
||||||
this.node = node;
|
this.node = node;
|
||||||
|
this.props.innerRef.current = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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() {
|
function Loading() {
|
||||||
return (
|
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)]" />
|
<SpinnerIcon className="mb-6 h-12 w-12 fill-[rgb(225,225,225,0.9)] dark:fill-[rgba(255,255,255,0.6)]" />
|
||||||
</div>
|
</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) {
|
if (!d) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const data: {uid: string, action: string} = d;
|
const data: {uid: string, action: string} = d;
|
||||||
|
|
||||||
|
@ -126,10 +126,23 @@ export async function initialize({mockedApi, page, bodyStyle, ...options}: {
|
||||||
document.body.appendChild(scriptTag);
|
document.body.appendChild(scriptTag);
|
||||||
}, {url, options});
|
}, {url, options});
|
||||||
|
|
||||||
|
const commentsFrameSelector = 'iframe[title="comments-frame"]';
|
||||||
|
|
||||||
await page.waitForSelector('iframe');
|
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 {
|
return {
|
||||||
frame: page.frameLocator('iframe[title="comments-frame"]')
|
frame: page.frameLocator(commentsFrameSelector)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue